End-to-End Testing Next.js with Playwright

End-to-End Testing Next.js with Playwright

Playwright is the best end-to-end testing tool for Next.js applications — it can start your dev server automatically, test across multiple browsers, and handle modern patterns like server-side rendering and streaming. The built-in Next.js integration makes setup minimal and CI integration straightforward.

Key Takeaways

Let Playwright start your dev server. Configure webServer in playwright.config.js to run next dev or next start before tests. Playwright waits for the server to be ready and tears it down after. No manual server management needed.

Use storage state for authentication. Log in once in a global setup file and save the browser storage state to a file. Every test that needs authentication loads that state. This avoids re-authenticating in every test, which is slow and fragile.

Prefer role-based selectors over CSS. getByRole('button', { name: /submit/i }) is more resilient than .submit-btn. Role selectors mirror how assistive technologies interact with your page and break less often when markup changes.

Test user flows, not implementation. An E2E test should simulate a real user completing a task — navigate, fill a form, submit, verify the result. Don't test internal state, component structure, or API responses directly.

Run tests in parallel with sharding in CI. Playwright runs tests in parallel by default within a single machine. For CI, use --shard=1/4 across multiple workers to distribute the load. A 20-minute test suite becomes 5 minutes.

Unit tests verify that individual functions and components work correctly. End-to-end tests verify that your entire application — Next.js server, API routes, database, and client-side JavaScript — works together correctly from a user's perspective.

Playwright is the current standard for E2E testing Next.js applications. It supports Chromium, Firefox, and WebKit; handles modern web patterns including streaming SSR and client-side navigation; and integrates cleanly with Next.js's development server.

Installation and Initial Setup

npm init playwright@latest

This installs Playwright and generates a playwright.config.js at your project root. The generated config is a good starting point but needs customization for Next.js:

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

export default defineConfig({
  testDir: './e2e',
  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: 'webkit', use: { ...devices['Desktop Safari'] } },
  ],

  // Start Next.js dev server before tests
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120 * 1000,
  },
})

The webServer configuration is the key piece. Playwright starts next dev, waits until http://localhost:3000 responds, runs all tests, and then stops the server. In CI (!process.env.CI is false), it always starts a fresh server. Locally, it reuses an existing dev server to avoid startup time.

Writing Your First Test

Create an e2e/ directory at your project root and write a basic navigation test:

// e2e/home.spec.js
import { test, expect } from '@playwright/test'

test('home page loads and shows navigation', async ({ page }) => {
  await page.goto('/')

  await expect(page).toHaveTitle(/HelpMeTest/)
  await expect(page.getByRole('navigation')).toBeVisible()
  await expect(page.getByRole('link', { name: /get started/i })).toBeVisible()
})

test('navigates to pricing page', async ({ page }) => {
  await page.goto('/')

  await page.getByRole('link', { name: /pricing/i }).click()

  await expect(page).toHaveURL('/pricing')
  await expect(page.getByRole('heading', { name: /pricing/i })).toBeVisible()
})

Run tests:

npx playwright test
npx playwright test --ui        # Interactive test runner
npx playwright test e2e/home    # Run specific file

Testing Forms

Form testing is where E2E tests provide the most value — they test the full submit cycle including client-side validation, API calls, and success/error states.

// e2e/contact.spec.js
import { test, expect } from '@playwright/test'

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

  await page.getByLabel('Name').fill('Alice Smith')
  await page.getByLabel('Email').fill('alice@example.com')
  await page.getByLabel('Message').fill('Hello, I have a question about your pricing.')

  await page.getByRole('button', { name: /send message/i }).click()

  await expect(page.getByText('Message sent successfully')).toBeVisible()
  await expect(page.getByLabel('Name')).toHaveValue('')
})

test('shows validation errors for empty submission', async ({ page }) => {
  await page.goto('/contact')

  await page.getByRole('button', { name: /send message/i }).click()

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

test('shows error for invalid email', async ({ page }) => {
  await page.goto('/contact')

  await page.getByLabel('Name').fill('Alice')
  await page.getByLabel('Email').fill('not-an-email')

  await page.getByRole('button', { name: /send message/i }).click()

  await expect(page.getByText('Please enter a valid email')).toBeVisible()
})

Authentication Testing with Storage State

The most common mistake in E2E test suites is authenticating inside every test. This is slow (a login flow can take 2-3 seconds) and creates brittle dependencies on the auth system.

The correct approach: authenticate once, save the browser state (cookies, localStorage), and load that state in tests that need authentication.

Create a global setup file:

// e2e/global-setup.js
import { chromium } from '@playwright/test'

async function globalSetup() {
  const browser = await chromium.launch()
  const page = await browser.newPage()

  await page.goto('http://localhost:3000/login')
  await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL)
  await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD)
  await page.getByRole('button', { name: /sign in/i }).click()

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

  // Save the authenticated state
  await page.context().storageState({ path: 'e2e/.auth/user.json' })

  await browser.close()
}

export default globalSetup

Reference it in playwright.config.js:

export default defineConfig({
  globalSetup: './e2e/global-setup.js',
  // ...
  projects: [
    {
      name: 'setup',
      testMatch: /global-setup/,
    },
    {
      name: 'authenticated',
      use: {
        storageState: 'e2e/.auth/user.json',
      },
      dependencies: ['setup'],
    },
    {
      name: 'unauthenticated',
      // No storage state — starts logged out
    },
  ],
})

Add the auth file to .gitignore:

e2e/.auth/

Now authenticated tests load the saved state instantly:

// e2e/dashboard.spec.js
// This test uses the 'authenticated' project, which loads storageState automatically

import { test, expect } from '@playwright/test'

test('dashboard shows user stats', async ({ page }) => {
  await page.goto('/dashboard')

  // No login needed — storage state is already loaded
  await expect(page.getByText('Total Tests')).toBeVisible()
  await expect(page.getByText('Pass Rate')).toBeVisible()
})

test('protected routes redirect unauthenticated users', async ({ page }) => {
  // This test uses the 'unauthenticated' project
  await page.goto('/dashboard')
  await expect(page).toHaveURL('/login')
})

Testing Next.js Navigation

Next.js client-side navigation (via <Link>) doesn't trigger full page loads, which can cause timing issues in tests. Playwright handles this correctly because it waits for network idle and navigation events.

// e2e/navigation.spec.js
import { test, expect } from '@playwright/test'

test('client-side navigation preserves state', async ({ page }) => {
  await page.goto('/products')

  // Apply a filter
  await page.getByRole('combobox', { name: /category/i }).selectOption('Electronics')
  await expect(page.getByTestId('product-count')).toContainText('24 products')

  // Navigate to a product
  await page.getByRole('link', { name: /view product/i }).first().click()
  await expect(page).toHaveURL(/\/products\/\d+/)

  // Navigate back — filter should be gone (stateless navigation)
  await page.goBack()
  await expect(page).toHaveURL('/products')
})

test('Next.js Link prefetching works', async ({ page }) => {
  await page.goto('/')

  // Hover over a link to trigger prefetch
  await page.getByRole('link', { name: /about/i }).hover()

  // Click and verify fast navigation
  await page.getByRole('link', { name: /about/i }).click()
  await expect(page).toHaveURL('/about')
})

Testing API Routes via the UI

Don't test API routes directly in E2E tests — that's what unit tests are for. But do test the user-facing behavior that happens when APIs succeed or fail.

To simulate API failures, use Playwright's route interception:

// e2e/error-handling.spec.js
import { test, expect } from '@playwright/test'

test('shows error state when API fails', async ({ page }) => {
  // Intercept the products API and return an error
  await page.route('/api/products', async (route) => {
    await route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Internal Server Error' }),
    })
  })

  await page.goto('/products')

  await expect(page.getByRole('alert')).toContainText('Failed to load products')
  await expect(page.getByRole('button', { name: /try again/i })).toBeVisible()
})

test('shows network error state', async ({ page }) => {
  await page.route('/api/products', (route) => route.abort('failed'))

  await page.goto('/products')

  await expect(page.getByText(/connection error/i)).toBeVisible()
})

This is particularly useful for testing error boundaries, retry logic, and user-facing error messages that would be hard to trigger in unit tests.

Testing File Uploads

Next.js apps often include file upload functionality. Playwright handles this with setInputFiles:

test('uploads profile picture', async ({ page }) => {
  await page.goto('/settings/profile')

  // Trigger the file input (might be hidden behind a custom button)
  const fileInput = page.locator('input[type="file"]')
  await fileInput.setInputFiles('e2e/fixtures/test-avatar.jpg')

  // Wait for upload to complete
  await expect(page.getByAltText('Profile picture preview')).toBeVisible()
  await page.getByRole('button', { name: /save/i }).click()

  await expect(page.getByText('Profile updated')).toBeVisible()
})

CI Integration with GitHub Actions

# .github/workflows/playwright.yml
name: Playwright Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    timeout-minutes: 60
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

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

      - name: Install dependencies
        run: npm ci

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

      - name: Build Next.js application
        run: npm run build

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

      - name: Upload test report
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7

For faster CI on large test suites, use sharding to run tests in parallel across multiple runners:

strategy:
  matrix:
    shard: [1, 2, 3, 4]

steps:
  - name: Run Playwright tests (shard ${{ matrix.shard }}/4)
    run: npx playwright test --shard=${{ matrix.shard }}/4

Use next build && next start in CI instead of next dev for tests that are closer to production behavior. The build step catches type errors and compilation issues that the dev server tolerates.

Selectors That Survive Refactoring

Brittle selectors are the number one reason E2E tests require constant maintenance. When markup changes, CSS selectors break. Prefer selectors that are tied to semantic meaning:

// Fragile — breaks when CSS class or DOM structure changes
page.locator('.product-grid > div:first-child button.add-to-cart')

// Better — tied to ARIA role and name
page.getByRole('button', { name: /add to cart/i })

// Even better — use test IDs for complex components without good ARIA labels
page.getByTestId('add-to-cart-button')

Add data-testid attributes to components that are important to test but don't have natural accessible names. This is a small cost in your component code that pays off in test maintainability.

Playwright's built-in locators — getByRole, getByLabel, getByText, getByPlaceholder, getByAltText, getByTitle, and getByTestId — cover the vast majority of what you need. Avoid using page.locator('css=...') for anything that's not a last resort.

With Playwright configured, your Next.js tests cover the full user journey from browser to server and back — the gaps that unit tests can't reach.

Read more

Start now free