Skip to content
July 15, 2025·16 min read

Testing Next.js with Playwright: E2E, Authentication, and CI

Write reliable end-to-end tests for Next.js with Playwright — auth state reuse, page object pattern, API mocking, visual regression, and GitHub Actions integration.

Back

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 browsers

Configuration

// 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: 7

Common Pitfalls

  • waitForTimeout everywhere: use waitForURL, waitForLoadState, or waitFor on elements instead — time-based waits are flaky
  • No data-testid attributes: CSS class selectors break when styles change — add semantic data-testid attributes 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

Resources

Aïcha Imène DAHOUMANE
About the author
Aïcha Imène DAHOUMANE
Salesforce Administrator & Developer · Consultant

Salesforce developer with a background in IT infrastructure — building reliable, maintainable solutions with Apex, Flows, LWC, and CI/CD.

ShareShare on LinkedIn