Unit tests tell you your functions work. E2E tests tell you your application works. Playwright is the best tool for Next.js E2E testing — it understands the web, runs fast, and integrates cleanly with GitHub Actions.
Prerequisites
- Next.js project with a running dev server
- Node.js 18+
npm install -D @playwright/test
npx playwright install chromium firefox # Install browsersConfiguration
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, // Retry on CI only
workers: process.env.CI ? 1 : undefined,
reporter: [['html'], ['github']],
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
// Setup project for authentication
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
dependencies: ['setup'],
},
{
name: 'mobile',
use: { ...devices['iPhone 13'] },
dependencies: ['setup'],
},
],
webServer: {
command: 'npm run build && npm run start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});Authentication State Reuse
The key optimization — authenticate once, reuse across all tests:
// tests/e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import path from 'path';
const authFile = path.join(__dirname, '.auth/user.json');
setup('authenticate', async ({ page }) => {
await page.goto('/auth/login');
await page.fill('[name="email"]', process.env.TEST_USER_EMAIL!);
await page.fill('[name="password"]', process.env.TEST_USER_PASSWORD!);
await page.click('button[type="submit"]');
// Wait for redirect to dashboard
await page.waitForURL('/dashboard');
await expect(page.locator('h1')).toBeVisible();
// Save auth state to disk — reused by all tests
await page.context().storageState({ path: authFile });
});// playwright.config.ts — use the auth state
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'tests/e2e/.auth/user.json',
},
dependencies: ['setup'],
},Page Object Pattern
Don't write selectors in test files — encapsulate them in page objects:
// tests/e2e/pages/ProjectsPage.ts
import { Page, Locator, expect } from '@playwright/test';
export class ProjectsPage {
readonly page: Page;
readonly heading: Locator;
readonly projectCards: Locator;
readonly searchInput: Locator;
constructor(page: Page) {
this.page = page;
this.heading = page.getByRole('heading', { name: 'Projects' });
this.projectCards = page.locator('[data-testid="project-card"]');
this.searchInput = page.getByPlaceholder('Search projects...');
}
async goto() {
await this.page.goto('/projects');
await expect(this.heading).toBeVisible();
}
async search(query: string) {
await this.searchInput.fill(query);
await this.page.waitForTimeout(300); // Debounce
}
async getProjectCount() {
return this.projectCards.count();
}
async clickProject(name: string) {
await this.projectCards.filter({ hasText: name }).click();
await this.page.waitForURL(/\/projects\/.+/);
}
}// tests/e2e/projects.spec.ts
import { test, expect } from '@playwright/test';
import { ProjectsPage } from './pages/ProjectsPage';
test.describe('Projects', () => {
test('displays all projects', async ({ page }) => {
const projectsPage = new ProjectsPage(page);
await projectsPage.goto();
const count = await projectsPage.getProjectCount();
expect(count).toBeGreaterThan(0);
});
test('filters projects by search', async ({ page }) => {
const projectsPage = new ProjectsPage(page);
await projectsPage.goto();
await projectsPage.search('Salesforce');
const count = await projectsPage.getProjectCount();
expect(count).toBeGreaterThanOrEqual(1);
});
test('navigates to project detail', async ({ page }) => {
const projectsPage = new ProjectsPage(page);
await projectsPage.goto();
await projectsPage.clickProject('My Project');
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
});
});API Mocking for Isolation
Mock external APIs to make tests fast and reliable:
// tests/e2e/blog.spec.ts
import { test, expect } from '@playwright/test';
test('shows blog posts from API', async ({ page }) => {
// Mock the API before navigating
await page.route('/api/posts', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: '1', title: 'Test Post', excerpt: 'Test excerpt', date: '2026-01-01' },
]),
});
});
await page.goto('/blog');
await expect(page.getByText('Test Post')).toBeVisible();
});
test('handles API error gracefully', async ({ page }) => {
await page.route('/api/posts', async route => {
await route.fulfill({ status: 500 });
});
await page.goto('/blog');
await expect(page.getByText(/error|failed|sorry/i)).toBeVisible();
});Visual Regression Testing
test('homepage matches snapshot', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
// Hide dynamic content before snapshotting
await page.locator('[data-testid="current-date"]').evaluate(el => el.remove());
await expect(page).toHaveScreenshot('homepage.png', {
maxDiffPixelRatio: 0.02, // Allow 2% pixel difference
fullPage: true,
});
});First run: npx playwright test --update-snapshots to create baselines.
GitHub Actions Integration
# .github/workflows/e2e.yml
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium
- name: Run E2E Tests
run: npx playwright test
env:
PLAYWRIGHT_BASE_URL: http://localhost:3000
TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7Common Pitfalls
waitForTimeouteverywhere: usewaitForURL,waitForLoadState, orwaitForon elements instead — time-based waits are flaky- No
data-testidattributes: CSS class selectors break when styles change — add semanticdata-testidattributes to interactive elements - Running all tests on every commit: scope slow E2E tests to PRs + main pushes, not feature branch commits
- Auth state not gitignored:
.auth/directory contains session tokens — add to.gitignore