End-to-End Testing with Playwright and Next.js: From Zero to CI Pipeline

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

Most teams ship code without a safety net. They test manually, cross their fingers on deploy, and find out about regressions when users report them. End-to-end testing changes that — it lets you simulate real user behavior and catch breakages before they reach production.

Playwright is the testing framework that has taken over in 2026. Maintained by Microsoft, it supports Chromium, Firefox, and WebKit with a single API, handles modern web patterns out of the box, and has first-class TypeScript support.

In this tutorial, we'll build a complete E2E testing setup for a Next.js application — from installing Playwright to running tests in CI with GitHub Actions.

Why Playwright over Cypress? Playwright supports multi-tab scenarios, iframes, file downloads, and native browser contexts. It's faster because it runs tests in parallel by default, and its auto-waiting mechanism makes tests inherently more stable.

What You'll Learn

By the end of this tutorial, you'll be able to:

  • Set up Playwright in a Next.js project with TypeScript
  • Write resilient E2E tests using locators and auto-waiting
  • Organize tests with the Page Object Model pattern
  • Capture visual regression snapshots
  • Run accessibility audits with @axe-core/playwright
  • Integrate everything into a GitHub Actions CI pipeline
  • Debug failing tests with traces, screenshots, and video recordings

Prerequisites

Before starting, make sure you have:

  • Node.js 18+ installed
  • A Next.js project (App Router or Pages Router — both work)
  • Basic knowledge of TypeScript and React
  • A GitHub repository (for the CI section)
  • A code editor (VS Code recommended — Playwright has an excellent VS Code extension)

Project Setup

We'll work with a simple Next.js application that has a home page, a contact form, and a dashboard. If you don't have a project, create one:

npx create-next-app@latest my-app --typescript --app --tailwind
cd my-app

Step 1: Install Playwright

Playwright ships its own init command that scaffolds everything you need:

npm init playwright@latest

When prompted, choose the following:

  • Where to put your tests?e2e
  • Add a GitHub Actions workflow? → Yes
  • Install Playwright browsers? → Yes

This installs @playwright/test, downloads browser binaries, and creates the configuration files.

Your project now has:

my-app/
├── e2e/
│   └── example.spec.ts        # Sample test
├── playwright.config.ts        # Configuration
├── .github/
│   └── workflows/
│       └── playwright.yml      # CI workflow
└── ...

Step 2: Configure Playwright for Next.js

The generated config needs adjustments for Next.js. Replace the contents of playwright.config.ts:

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: process.env.CI
    ? [['html'], ['github']]
    : [['html'], ['list']],
 
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'on-first-retry',
  },
 
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    {
      name: 'mobile-chrome',
      use: { ...devices['Pixel 7'] },
    },
  ],
 
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});

Key decisions here:

  • webServer starts your Next.js dev server before tests run and tears it down after. In CI, it always starts fresh; locally, it reuses an existing server if one is running.
  • trace: 'on-first-retry' records a full trace when a test fails, giving you a timeline of actions, network requests, and DOM snapshots.
  • fullyParallel: true runs test files in parallel across workers for speed.
  • Four projects test across Chrome, Firefox, Safari, and mobile Chrome.

Step 3: Write Your First Test

Delete the sample test and create e2e/home.spec.ts:

import { test, expect } from '@playwright/test';
 
test.describe('Home Page', () => {
  test('should display the hero section', async ({ page }) => {
    await page.goto('/');
 
    // Check that the page has loaded
    await expect(page).toHaveTitle(/My App/);
 
    // Verify the hero heading is visible
    const heading = page.getByRole('heading', { level: 1 });
    await expect(heading).toBeVisible();
  });
 
  test('should navigate to the about page', async ({ page }) => {
    await page.goto('/');
 
    // Click the navigation link
    await page.getByRole('link', { name: /about/i }).click();
 
    // Verify the URL changed
    await expect(page).toHaveURL(/\/about/);
  });
 
  test('should be responsive on mobile', async ({ page }) => {
    await page.setViewportSize({ width: 375, height: 667 });
    await page.goto('/');
 
    // Mobile menu button should be visible
    const menuButton = page.getByRole('button', { name: /menu/i });
    await expect(menuButton).toBeVisible();
  });
});

Run the test:

npx playwright test

You should see results for all four browser projects. To run only Chromium:

npx playwright test --project=chromium

To see the test in action with a visible browser:

npx playwright test --headed --project=chromium

Use getByRole locators. Playwright's role-based locators (getByRole, getByLabel, getByPlaceholder, getByText) are more resilient than CSS selectors. They match how users and assistive technologies interact with your UI, and they survive refactors that change class names or DOM structure.

Step 4: Testing Forms and User Interactions

Forms are where most bugs hide. Create e2e/contact-form.spec.ts:

import { test, expect } from '@playwright/test';
 
test.describe('Contact Form', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/contact');
  });
 
  test('should submit the form successfully', async ({ page }) => {
    // Fill in the form fields
    await page.getByLabel('Name').fill('Jane Developer');
    await page.getByLabel('Email').fill('jane@example.com');
    await page.getByLabel('Message').fill('Hello, I have a question about your services.');
 
    // Submit the form
    await page.getByRole('button', { name: /send|submit/i }).click();
 
    // Verify success message
    await expect(
      page.getByText(/thank you|message sent/i)
    ).toBeVisible();
  });
 
  test('should show validation errors for empty fields', async ({ page }) => {
    // Submit without filling anything
    await page.getByRole('button', { name: /send|submit/i }).click();
 
    // Check for validation messages
    await expect(page.getByText(/name is required/i)).toBeVisible();
    await expect(page.getByText(/email is required/i)).toBeVisible();
  });
 
  test('should validate email format', async ({ page }) => {
    await page.getByLabel('Name').fill('Jane');
    await page.getByLabel('Email').fill('not-an-email');
    await page.getByLabel('Message').fill('Test message');
 
    await page.getByRole('button', { name: /send|submit/i }).click();
 
    await expect(
      page.getByText(/valid email/i)
    ).toBeVisible();
  });
});

Step 5: The Page Object Model

As your test suite grows, you'll notice repeated patterns — navigating to a page, filling in forms, checking specific elements. The Page Object Model (POM) abstracts these into reusable classes.

Create e2e/pages/contact-page.ts:

import { type Locator, type Page, expect } from '@playwright/test';
 
export class ContactPage {
  readonly page: Page;
  readonly nameInput: Locator;
  readonly emailInput: Locator;
  readonly messageInput: Locator;
  readonly submitButton: Locator;
  readonly successMessage: Locator;
 
  constructor(page: Page) {
    this.page = page;
    this.nameInput = page.getByLabel('Name');
    this.emailInput = page.getByLabel('Email');
    this.messageInput = page.getByLabel('Message');
    this.submitButton = page.getByRole('button', { name: /send|submit/i });
    this.successMessage = page.getByText(/thank you|message sent/i);
  }
 
  async goto() {
    await this.page.goto('/contact');
  }
 
  async fillForm(name: string, email: string, message: string) {
    await this.nameInput.fill(name);
    await this.emailInput.fill(email);
    await this.messageInput.fill(message);
  }
 
  async submit() {
    await this.submitButton.click();
  }
 
  async expectSuccess() {
    await expect(this.successMessage).toBeVisible();
  }
}

Now your tests become much cleaner:

import { test } from '@playwright/test';
import { ContactPage } from './pages/contact-page';
 
test.describe('Contact Form', () => {
  test('should submit successfully', async ({ page }) => {
    const contactPage = new ContactPage(page);
    await contactPage.goto();
    await contactPage.fillForm('Jane', 'jane@example.com', 'Hello!');
    await contactPage.submit();
    await contactPage.expectSuccess();
  });
});

The POM pattern pays off quickly:

  • If the form UI changes, you update one file instead of every test
  • Tests read like user stories: "go to page, fill form, submit, expect success"
  • New team members can write tests without learning the DOM structure

Step 6: Mocking API Calls

E2E tests should be deterministic. If your form calls an API, you don't want tests failing because the backend is down. Playwright lets you intercept network requests:

import { test, expect } from '@playwright/test';
import { ContactPage } from './pages/contact-page';
 
test('should handle API errors gracefully', async ({ page }) => {
  // Intercept the API call and return an error
  await page.route('**/api/contact', (route) => {
    route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Internal server error' }),
    });
  });
 
  const contactPage = new ContactPage(page);
  await contactPage.goto();
  await contactPage.fillForm('Jane', 'jane@example.com', 'Hello!');
  await contactPage.submit();
 
  // Verify the error is shown to the user
  await expect(
    page.getByText(/something went wrong|try again/i)
  ).toBeVisible();
});
 
test('should show loading state during submission', async ({ page }) => {
  // Delay the API response to observe loading state
  await page.route('**/api/contact', async (route) => {
    await new Promise((resolve) => setTimeout(resolve, 2000));
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ success: true }),
    });
  });
 
  const contactPage = new ContactPage(page);
  await contactPage.goto();
  await contactPage.fillForm('Jane', 'jane@example.com', 'Hello!');
  await contactPage.submit();
 
  // The button should show a loading indicator
  await expect(
    page.getByRole('button', { name: /sending|loading/i })
  ).toBeVisible();
});

Step 7: Visual Regression Testing

Visual regression tests catch unintended UI changes by comparing screenshots against a baseline. Playwright has this built in:

import { test, expect } from '@playwright/test';
 
test.describe('Visual Regression', () => {
  test('home page matches snapshot', async ({ page }) => {
    await page.goto('/');
    // Wait for fonts, images, and animations to settle
    await page.waitForLoadState('networkidle');
 
    await expect(page).toHaveScreenshot('home-page.png', {
      maxDiffPixelRatio: 0.01,
    });
  });
 
  test('contact form matches snapshot', async ({ page }) => {
    await page.goto('/contact');
    await page.waitForLoadState('networkidle');
 
    await expect(page).toHaveScreenshot('contact-form.png', {
      maxDiffPixelRatio: 0.01,
    });
  });
 
  test('dashboard cards layout', async ({ page }) => {
    await page.goto('/dashboard');
    await page.waitForLoadState('networkidle');
 
    // Screenshot a specific element
    const cardsSection = page.getByTestId('dashboard-cards');
    await expect(cardsSection).toHaveScreenshot('dashboard-cards.png');
  });
});

The first time you run these tests, Playwright creates baseline screenshots in e2e/__screenshots__/. On subsequent runs, it compares against the baseline and fails if the difference exceeds your threshold.

To update baselines after intentional changes:

npx playwright test --update-snapshots

Visual tests are project-specific. Screenshots differ between operating systems due to font rendering. Run --update-snapshots in your CI environment (Linux) and commit those baselines. Don't use locally generated macOS screenshots as baselines for Linux CI.

Step 8: Accessibility Testing with axe-core

Accessibility isn't optional — it's a legal requirement in many jurisdictions and the right thing to do. The @axe-core/playwright package integrates the industry-standard axe engine into your Playwright tests.

Install it:

npm install -D @axe-core/playwright

Create e2e/accessibility.spec.ts:

import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
 
test.describe('Accessibility', () => {
  test('home page has no critical violations', async ({ page }) => {
    await page.goto('/');
 
    const results = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
      .analyze();
 
    expect(results.violations).toEqual([]);
  });
 
  test('contact form is accessible', async ({ page }) => {
    await page.goto('/contact');
 
    const results = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa'])
      .analyze();
 
    // Log violations for debugging
    if (results.violations.length > 0) {
      console.log('Accessibility violations:');
      results.violations.forEach((v) => {
        console.log(`  [${v.impact}] ${v.id}: ${v.description}`);
        v.nodes.forEach((n) => {
          console.log(`    - ${n.html}`);
        });
      });
    }
 
    expect(results.violations).toEqual([]);
  });
 
  test('navigation is keyboard accessible', async ({ page }) => {
    await page.goto('/');
 
    // Tab through the navigation
    await page.keyboard.press('Tab');
    const firstFocused = await page.evaluate(() =>
      document.activeElement?.tagName.toLowerCase()
    );
    expect(firstFocused).toBe('a');
 
    // Verify focus is visible (not hidden by CSS)
    const focusedElement = page.locator(':focus');
    await expect(focusedElement).toBeVisible();
    await expect(focusedElement).toHaveCSS('outline-style', /(solid|auto)/);
  });
});

This catches real-world accessibility issues:

  • Missing alt text on images
  • Insufficient color contrast
  • Form inputs without labels
  • Missing ARIA attributes
  • Keyboard navigation traps

Step 9: Testing Authentication Flows

If your app has login functionality, you'll want to test it without logging in every single test. Playwright's storage state feature lets you authenticate once and reuse the session:

Create a setup file e2e/auth.setup.ts:

import { test as setup, expect } from '@playwright/test';
import path from 'node:path';
 
const authFile = path.join(__dirname, '../.playwright/.auth/user.json');
 
setup('authenticate', async ({ page }) => {
  await page.goto('/login');
 
  await page.getByLabel('Email').fill('test@example.com');
  await page.getByLabel('Password').fill('test-password');
  await page.getByRole('button', { name: /sign in/i }).click();
 
  // Wait for redirect after login
  await page.waitForURL('/dashboard');
 
  // Save the authenticated state
  await page.context().storageState({ path: authFile });
});

Update playwright.config.ts to use this setup:

export default defineConfig({
  // ... existing config
 
  projects: [
    // Setup project - runs first
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
 
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: '.playwright/.auth/user.json',
      },
      dependencies: ['setup'],
    },
    // ... other browser projects with the same storageState
  ],
});

Now all tests in the chromium project start with an authenticated session — no login step needed.

Add .playwright/ to your .gitignore:

.playwright/

Step 10: Debugging Failing Tests

Tests will fail. Here's your debugging toolkit:

Playwright UI Mode

The best debugging experience. It shows every action, network request, and DOM snapshot in a timeline:

npx playwright test --ui

Trace Viewer

When tests fail in CI, the trace file contains everything you need:

npx playwright show-trace test-results/home-page-chromium/trace.zip

The trace viewer shows:

  • A timeline of every action
  • DOM snapshots at each step
  • Network requests and responses
  • Console logs
  • A filmstrip of the test execution

Code Generation

If you're not sure which locators to use, let Playwright generate them:

npx playwright codegen localhost:3000

This opens a browser and a code generator. As you interact with the page, Playwright writes the test code for you.

VS Code Extension

Install the Playwright Test for VS Code extension for:

  • Running individual tests from the editor
  • Stepping through tests with breakpoints
  • Picking locators by clicking on elements
  • Live test results in the test explorer

Step 11: Organizing a Large Test Suite

As your test suite grows, structure matters. Here's a proven layout:

e2e/
├── fixtures/
│   └── test-data.ts            # Shared test data
├── pages/
│   ├── home-page.ts            # Page Object: Home
│   ├── contact-page.ts         # Page Object: Contact
│   └── dashboard-page.ts       # Page Object: Dashboard
├── specs/
│   ├── home.spec.ts            # Home page tests
│   ├── contact-form.spec.ts    # Contact form tests
│   ├── dashboard.spec.ts       # Dashboard tests
│   ├── accessibility.spec.ts   # A11y tests
│   └── visual.spec.ts          # Visual regression tests
├── auth.setup.ts               # Authentication setup
└── global-setup.ts             # Global setup (seed DB, etc.)

Custom Fixtures

Playwright's fixture system lets you create reusable test helpers:

// e2e/fixtures/test-data.ts
import { test as base } from '@playwright/test';
import { ContactPage } from '../pages/contact-page';
 
type Fixtures = {
  contactPage: ContactPage;
};
 
export const test = base.extend<Fixtures>({
  contactPage: async ({ page }, use) => {
    const contactPage = new ContactPage(page);
    await contactPage.goto();
    await use(contactPage);
  },
});
 
export { expect } from '@playwright/test';

Now tests can request the fixture directly:

import { test, expect } from '../fixtures/test-data';
 
test('should submit form', async ({ contactPage }) => {
  await contactPage.fillForm('Jane', 'jane@example.com', 'Hello!');
  await contactPage.submit();
  await contactPage.expectSuccess();
});

Step 12: GitHub Actions CI Pipeline

The final piece is running tests automatically on every push and pull request. Create .github/workflows/playwright.yml:

name: Playwright Tests
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
jobs:
  test:
    timeout-minutes: 15
    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
 
      - name: Build the application
        run: npm run build
 
      - name: Run Playwright tests
        run: npx playwright test
        env:
          CI: true
 
      - name: Upload test report
        uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30
 
      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: test-results
          path: test-results/
          retention-days: 7

Key details:

  • npm run build before testing ensures your app builds correctly and tests run against the production build — use command: 'npm run start' in your Playwright config for CI to test the production server instead of dev.
  • Artifacts are uploaded on failure so you can download traces, screenshots, and videos to debug.
  • timeout-minutes: 15 prevents runaway tests from eating your CI minutes.

CI-Specific Playwright Config

Update your Playwright config to use the production server in CI:

webServer: {
  command: process.env.CI ? 'npm run start' : 'npm run dev',
  url: 'http://localhost:3000',
  reuseExistingServer: !process.env.CI,
  timeout: 120_000,
},

Troubleshooting

Tests pass locally but fail in CI

This is almost always caused by timing issues or environment differences:

  • Use await page.waitForLoadState('networkidle') before assertions that depend on dynamic content
  • Increase timeouts for slow CI environments: test.setTimeout(60_000)
  • Check if your CI has the required system dependencies — npx playwright install --with-deps handles this

Flaky tests

Flakiness usually comes from racing conditions:

  • Don't use page.waitForTimeout() — use Playwright's built-in auto-waiting instead
  • Use await expect(locator).toBeVisible() instead of checking immediately
  • For animations, use await page.waitForFunction() to wait for the animation to complete

Screenshot tests fail with minor differences

Font rendering differs between OS and even Docker images:

  • Always generate baselines in the same environment where tests run (CI)
  • Use maxDiffPixelRatio: 0.01 to allow for sub-pixel rendering differences
  • Consider masking dynamic content like timestamps:
await expect(page).toHaveScreenshot('page.png', {
  mask: [page.getByTestId('timestamp')],
});

Next Steps

You now have a production-grade E2E testing setup. Here are ways to take it further:

  • Add API testing — Playwright's request fixture lets you test API endpoints directly without a browser
  • Implement component testing — Playwright's experimental component testing lets you test React components in isolation
  • Set up Playwright's sharding — split tests across multiple CI machines for faster feedback
  • Add performance metrics — use page.metrics() to track Core Web Vitals in your tests
  • Explore Playwright MCP — the new Model Context Protocol integration enables AI-powered test generation and self-healing tests

Conclusion

E2E testing with Playwright isn't just about catching bugs — it's about building confidence. When every pull request runs through your test suite, you know that navigation works, forms submit correctly, pages render without visual regressions, and your app is accessible to everyone.

The setup takes a couple of hours. The peace of mind lasts forever.

Start with the critical user flows — login, signup, checkout — and expand from there. The Page Object Model keeps your tests maintainable, visual regression catches the things humans miss, and GitHub Actions ensures nothing slips through.

Your future self (and your users) will thank you.


Want to read more tutorials? Check out our latest tutorial on Supercharge Your Web Apps: A Beginner's Guide to Twilio's Voice JavaScript SDK.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles