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

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-appStep 1: Install Playwright
Playwright ships its own init command that scaffolds everything you need:
npm init playwright@latestWhen 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:
webServerstarts 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: trueruns 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 testYou should see results for all four browser projects. To run only Chromium:
npx playwright test --project=chromiumTo see the test in action with a visible browser:
npx playwright test --headed --project=chromiumUse 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-snapshotsVisual 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/playwrightCreate 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 --uiTrace Viewer
When tests fail in CI, the trace file contains everything you need:
npx playwright show-trace test-results/home-page-chromium/trace.zipThe 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:3000This 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: 7Key details:
npm run buildbefore testing ensures your app builds correctly and tests run against the production build — usecommand: '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: 15prevents 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-depshandles 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.01to 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
requestfixture 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.
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

Building an Autonomous AI Agent with Agentic RAG and Next.js
Learn how to build an AI agent that autonomously decides when and how to retrieve information from vector databases. A comprehensive hands-on guide using Vercel AI SDK and Next.js with executable examples.

Deploy a Next.js Application with Docker and CI/CD in Production
Learn how to containerize your Next.js application with Docker, set up a CI/CD pipeline with GitHub Actions, and deploy to production on a VPS. A complete guide from development to automated deployment.

Build a Full-Stack App with Drizzle ORM and Next.js 15: Type-Safe Database from Zero to Production
Learn how to build a type-safe full-stack application using Drizzle ORM with Next.js 15. This hands-on tutorial covers schema design, migrations, Server Actions, CRUD operations, and deployment with PostgreSQL.