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@latestThis 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 fileTesting 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 globalSetupReference 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: 7For 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 }}/4Use 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.