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.
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 filetests/- Directory for test filestests-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.
Related articles
- Testing End-to-End Testing with Playwright: A Practical Tutorial
Learn how to write reliable end-to-end tests with Playwright, including selectors, fixtures, auto-waiting, and patterns that avoid flakiness.
- Testing Visual Regression Testing with Playwright
Catch unintended UI changes automatically by comparing screenshots with Playwright's built-in visual comparison tools.
- Testing Testing Pyramid: Unit, Integration, E2E
What the testing pyramid actually means in modern apps, when to deviate, and how to keep each layer giving you the value it is supposed to.
- Next.js Next.js API Routes: Best Practices and Patterns
Build robust Next.js API routes with proper validation, error handling, authentication, rate limiting, and testing patterns for production apps.