Playwright is the best E2E testing tool for Next.js applications in 2026. It tests Chromium, Firefox, and WebKit with a single API, handles complex scenarios Cypress cannot (cross-origin iframes, multiple browser tabs, true mobile emulation), and runs faster on CI. The hard part is not setting it up. The hard part is writing tests that are resilient to UI changes.
What Playwright Provides
Playwright is a browser automation library maintained by Microsoft. It gives you programmatic control over real browser instances. You navigate to URLs, interact with elements, and assert that the page state matches your expectations.
Multi-browser. A single Playwright test can run against Chromium (Google Chrome, Microsoft Edge), Firefox, and WebKit (Safari). Real browser diversity is essential for catching browser-specific bugs before users do.
Multi-tab and cross-origin. Playwright can control multiple browser tabs simultaneously and navigate across different origins. This is useful for testing OAuth flows, popup windows, and multi-step flows that open new tabs.
True mobile emulation. Playwright's mobile emulation sets the viewport, device pixel ratio, user agent, and touch events to match real devices. It emulates touch events correctly, which matters for mobile-specific interactions.
Network interception. Playwright can intercept HTTP requests and return mocked responses. This lets you test loading states, error states, and edge cases without a real backend.
Setup in a Next.js Project
Install Playwright:
npm init playwright@latest
This generates a playwright.config.ts and an example test. Configure it for Next.js:
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
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",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
{ name: "mobile", use: { ...devices["iPhone 14"] } },
],
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});
The webServer configuration starts your Next.js dev server before tests run and shuts it down after.
Core Patterns: Locators and Assertions
The most important Playwright concept is using the right locator. Playwright provides semantic locators that are resilient to UI refactoring:
import { test, expect } from "@playwright/test";
test("user can sign in", async ({ page }) => {
await page.goto("/sign-in");
// Prefer semantic locators over CSS selectors
await page.getByLabel("Email").fill("user@example.com");
await page.getByLabel("Password").fill("password123");
await page.getByRole("button", { name: "Sign In" }).click();
// Assert navigation happened
await expect(page).toHaveURL("/dashboard");
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
});
The locator hierarchy (most resilient to least resilient):
getByRole— matches elements by their ARIA role and accessible namegetByLabel— matches form inputs by their labelgetByText— matches elements by their text contentgetByTestId— matches elements by adata-testidattribute you add- CSS selectors (
page.locator(".my-class")) — most brittle, avoid when possible
Network Interception: Mocking API Responses
Playwright can intercept network requests and return mocked responses. This is essential for testing loading states, error states, and edge cases where your backend cannot reliably produce a specific response:
test("shows error state when API fails", async ({ page }) => {
await page.route("/api/projects", (route) => {
route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ error: "Internal server error" }),
});
});
await page.goto("/projects");
await expect(page.getByText("Something went wrong")).toBeVisible();
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
});
You can also use page.route to delay responses and test loading states:
await page.route("/api/data", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 2000));
route.continue();
});
The Brittleness Problem
The hardest part of E2E testing is writing tests that do not break every time you make a small UI change. Brittle tests are worse than no tests, because they create noise that teaches developers to ignore test failures.
Brittle tests have these characteristics:
- They select elements by CSS class names that change when you refactor styles
- They assert on exact pixel positions or element counts that change legitimately
- They hard-code waits (
page.waitForTimeout(2000)) instead of waiting for specific conditions - They test implementation details rather than user-observable behavior
Resilient tests:
- Use semantic locators (
getByRole,getByLabel) - Assert on user-visible outcomes ("the user sees a success message") not internal state
- Use Playwright's built-in auto-waiting (assertions retry until the condition is met or times out)
- Test critical user flows, not every UI detail
A good E2E test reads like a user journey: "The user goes to the sign-in page, enters their credentials, clicks Sign In, and arrives at the dashboard."
Component Testing vs E2E Testing
Use Playwright's E2E tests for critical user flows: sign-in, sign-up, creating a record, completing a payment, core CRUD operations. These tests give you confidence that the most important paths through your application work correctly.
Use component-level tests (Vitest with Testing Library, or Storybook play functions) for component behavior: form validation, state transitions, edge cases in a single component's logic. Component tests are faster and more targeted than E2E tests.
Do not use E2E tests for everything. An E2E test suite that covers every UI state is expensive to run and expensive to maintain. Aim for 20-40 E2E tests covering the critical paths, and rely on component tests for the rest.
CI Integration with GitHub Actions
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
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
Upload the Playwright HTML report as a CI artifact so you can inspect test failures in detail when tests fail in CI.
Keep Reading
- Vitest Unit Testing Guide — complement E2E tests with fast component-level unit tests
- CI/CD for Small Engineering Teams — integrating Playwright into a full CI/CD pipeline
- GitHub Copilot Honest Review 2026 — using AI to generate Playwright test boilerplate
Pristren builds AI-powered software for teams. Zlyqor is our all-in-one workspace — chat, projects, time tracking, AI meeting summaries, and invoicing — in one tool. Try it free.