Skip to content
Codeloom
Next.js

E2E Testing Next.js Apps with Playwright

Set up Playwright for end-to-end testing of Next.js applications. Covers configuration, writing tests, handling auth, and CI integration.

·7 min read · By Codeloom
Intermediate 12 min read

What you'll learn

  • Setting up Playwright in a Next.js project
  • Writing your first E2E test for pages and navigation
  • Testing forms, server actions, and API routes
  • Managing authentication state across tests
  • Running Playwright tests in CI with GitHub Actions

Prerequisites

  • Working knowledge of [Next.js App Router](/blog/nextjs-app-router-basics)
  • Basic understanding of [testing concepts](/blog/testing-pyramid-unit-integration-e2e)

End-to-end tests verify that your application works from the user’s perspective. Playwright runs a real browser, navigates your pages, fills forms, clicks buttons, and asserts that the right content appears. For Next.js apps with server components, server actions, and middleware, E2E tests are the most reliable way to catch integration issues.

Setting up Playwright

Install Playwright and its dependencies:

npm init playwright@latest

The setup wizard creates:

  • playwright.config.ts - Configuration file
  • tests/ - Directory for test files
  • tests-examples/ - Example tests (you can delete these)

Configure it for Next.js:

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',

  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'mobile-chrome',
      use: { ...devices['Pixel 5'] },
    },
  ],

  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120 * 1000,
  },
});

The webServer block automatically starts your Next.js dev server before running tests and stops it after.

Your first test

// tests/home.spec.ts
import { test, expect } from '@playwright/test';

test('homepage renders the title', async ({ page }) => {
  await page.goto('/');

  await expect(page).toHaveTitle(/My App/);
  await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
});

test('navigation works', async ({ page }) => {
  await page.goto('/');

  await page.getByRole('link', { name: 'About' }).click();
  await expect(page).toHaveURL('/about');
  await expect(page.getByRole('heading', { name: 'About Us' })).toBeVisible();
});

Run tests:

npx playwright test

Open the HTML report:

npx playwright show-report

Testing forms and server actions

Test a form that uses a server action:

// tests/contact.spec.ts
import { test, expect } from '@playwright/test';

test('contact form submits successfully', async ({ page }) => {
  await page.goto('/contact');

  await page.getByLabel('Name').fill('Jane Doe');
  await page.getByLabel('Email').fill('jane@example.com');
  await page.getByLabel('Message').fill('Hello, this is a test message.');

  await page.getByRole('button', { name: 'Send Message' }).click();

  // Wait for the success message
  await expect(
    page.getByText('Thank you! Your message has been sent.')
  ).toBeVisible({ timeout: 5000 });
});

test('contact form shows validation errors', async ({ page }) => {
  await page.goto('/contact');

  // Submit without filling in required fields
  await page.getByRole('button', { name: 'Send Message' }).click();

  await expect(page.getByText('Name is required')).toBeVisible();
  await expect(page.getByText('Email is required')).toBeVisible();
});

Testing with authentication

Create a setup step that logs in and saves the auth state:

// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';

const authFile = 'tests/.auth/user.json';

setup('authenticate', async ({ page }) => {
  await page.goto('/login');

  await page.getByLabel('Email').fill('test@example.com');
  await page.getByLabel('Password').fill('testpassword123');
  await page.getByRole('button', { name: 'Sign In' }).click();

  // Wait for redirect after login
  await page.waitForURL('/dashboard');

  // Save the auth state (cookies, localStorage)
  await page.context().storageState({ path: authFile });
});

Configure projects to use the saved auth state:

// playwright.config.ts
export default defineConfig({
  projects: [
    // Setup project runs first
    { name: 'setup', testMatch: /.*\.setup\.ts/ },

    // Authenticated tests use saved state
    {
      name: 'authenticated',
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'tests/.auth/user.json',
      },
      dependencies: ['setup'],
    },

    // Unauthenticated tests
    {
      name: 'unauthenticated',
      use: { ...devices['Desktop Chrome'] },
      testMatch: /.*\.unauth\.spec\.ts/,
    },
  ],
});

Now authenticated tests start with a logged-in session without repeating the login flow:

// tests/dashboard.spec.ts
import { test, expect } from '@playwright/test';

test('dashboard shows user data', async ({ page }) => {
  // Already logged in via storageState
  await page.goto('/dashboard');

  await expect(page.getByText('Welcome back')).toBeVisible();
  await expect(page.getByRole('navigation')).toContainText('Dashboard');
});

Testing API routes

Test API routes directly using Playwright’s request context:

// tests/api.spec.ts
import { test, expect } from '@playwright/test';

test('GET /api/health returns 200', async ({ request }) => {
  const response = await request.get('/api/health');

  expect(response.ok()).toBeTruthy();

  const body = await response.json();
  expect(body).toEqual({ status: 'ok' });
});

test('POST /api/items creates an item', async ({ request }) => {
  const response = await request.post('/api/items', {
    data: {
      name: 'Test Item',
      price: 29.99,
    },
  });

  expect(response.status()).toBe(201);

  const item = await response.json();
  expect(item.name).toBe('Test Item');
  expect(item.id).toBeDefined();
});

test('GET /api/items/:id returns 404 for missing item', async ({ request }) => {
  const response = await request.get('/api/items/nonexistent');
  expect(response.status()).toBe(404);
});

Page object model

Organize tests with page objects for reusable interactions:

// tests/pages/login.page.ts
import type { Page, Locator } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Sign In' });
    this.errorMessage = page.getByRole('alert');
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/login.page';

test('shows error for invalid credentials', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('wrong@email.com', 'wrongpassword');

  await expect(loginPage.errorMessage).toContainText('Invalid credentials');
});

Visual regression testing

Capture screenshots and compare against baselines:

test('homepage visual regression', async ({ page }) => {
  await page.goto('/');

  // Wait for dynamic content to settle
  await page.waitForLoadState('networkidle');

  await expect(page).toHaveScreenshot('homepage.png', {
    maxDiffPixelRatio: 0.01,
  });
});

Update screenshots when the design changes intentionally:

npx playwright test --update-snapshots

Running in CI with GitHub Actions

# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps

      - name: Run Playwright tests
        run: npx playwright test
        env:
          DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}

      - uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7

Debugging failed tests

Playwright provides several debugging tools:

# Run in headed mode to see the browser
npx playwright test --headed

# Run with the Playwright inspector
npx playwright test --debug

# Run a specific test file
npx playwright test tests/login.spec.ts

# Run tests matching a pattern
npx playwright test -g "contact form"

Use page.pause() in your test to stop execution and open the inspector:

test('debug this test', async ({ page }) => {
  await page.goto('/dashboard');
  await page.pause(); // Opens the Playwright inspector
  // Continue debugging interactively...
});

Common pitfalls

Not waiting for hydration. Server-rendered pages need to hydrate before client-side interactions work. Use await expect(locator).toBeVisible() before clicking instead of await page.click() immediately.

Flaky tests from timing issues. Avoid page.waitForTimeout(). Use Playwright’s auto-waiting and assertions like toBeVisible(), toHaveURL(), and toHaveText() which automatically retry.

Testing against a production build. For CI, change the webServer command to npm run build && npm start instead of npm run dev to test the production build, which behaves differently (no hot reload, different caching).

Playwright gives you confidence that your Next.js application works correctly from end to end. Start with smoke tests for critical user flows, then expand coverage as your app grows.