Integration and End-to-End Testing with Playwright

Playwright lets a Next.js team prove that a user journey works in an actual browser instead of only proving that isolated functions return expected values. In this lesson, the outcome is practical: configure Playwright for a Next.js app, write tests that navigate real routes, assert server-rendered and client-rendered UI, control test data, and diagnose the failures that usually appear when browser tests meet routing, caching, authentication, and asynchronous React behavior.

In the Quality and User Experience section of this course, Playwright sits above unit tests and most integration tests. A unit test can prove that a price formatter works. A route handler test can prove that POST /api/cart returns 201. A Playwright end-to-end test proves that a shopper can open a product page, add the product to a cart, see navigation update, and complete the flow using the same browser APIs that a real user uses.

What Playwright Actually Runs

Playwright is a browser automation runner. It launches browser engines, creates isolated browser contexts, opens pages, performs actions, and waits for observable browser state. In a Next.js project, Playwright normally talks to a dev or production-like server through HTTP. That detail matters: it is not importing your React components and calling them as functions. It is exercising the route tree, middleware, server components, client components, route handlers, static assets, cookies, and network requests through the browser.

The core objects are browser, context, page, locator, and expect. A browser is the launched engine. A context is an isolated profile with its own cookies, storage, permissions, viewport, locale, and network state. A page is a tab. A locator is a resilient query that resolves when Playwright performs an action or assertion. That late resolution is why page.getByRole("button", { name: "Save" }) is usually better than storing an element handle early.

Next.js adds its own testing concerns. App Router pages may stream partial HTML and hydrate interactive islands later. Server Components can render text before Client Components become clickable. Route handlers can set cookies or return redirects. Middleware can rewrite a URL before the route loads. Playwright tests should therefore assert user-visible outcomes rather than implementation details such as component names or private CSS classes.

Configuration Anatomy

A typical Playwright setup has a playwright.config.ts file. The most important fields are testDir, use.baseURL, webServer, projects, retries, and trace. webServer starts Next.js before the tests. baseURL lets tests use relative paths. projects run the same suite across browser engines or device profiles. trace records screenshots, DOM snapshots, console output, and network events that make failures diagnosable.

import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./tests/e2e",
  timeout: 30_000,
  expect: { timeout: 5_000 },
  use: {
    baseURL: "http://127.0.0.1:3000",
    trace: "on-first-retry",
  },
  webServer: {
    command: "npm run dev",
    url: "http://127.0.0.1:3000",
    reuseExistingServer: !process.env.CI,
  },
  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
    { name: "mobile", use: { ...devices["Pixel 7"] } },
  ],
});

This configuration starts the Next.js development server, waits for the URL to respond, then runs tests from tests/e2e. On a developer machine it reuses an existing server, while CI gets a fresh process. The expected behavior is that a command such as npx playwright test opens Chromium and the mobile profile against the same app routes.

Example 1: Smoke Test a Rendered Route

The first useful test confirms that the home page resolves through Next.js routing, returns accessible landmarks, and exposes the primary call to action. It is intentionally small. A smoke test should fail quickly when the app cannot boot, a route is renamed, or a critical element disappears.

import { test, expect } from "@playwright/test";

test("home page exposes the primary journey", async ({ page }) => {
  await page.goto("/");

  await expect(page).toHaveTitle(/Course Agent/);
  await expect(page.getByRole("heading", { name: "Course Agent" })).toBeVisible();
  await expect(page.getByRole("link", { name: /Start learning/i })).toHaveAttribute("href", "/courses");
});

The deterministic output is a passing test when the document title contains Course Agent, the heading is visible, and the link points to /courses. If the text changes, update the assertion only if the visible product contract changed. If the route changed without an intended user-facing change, the test caught a regression.

Example 2: Test Client Interaction After Hydration

Next.js can send HTML before every client component is interactive. Playwright actions auto-wait for elements to be actionable, but your test still needs to express the behavior the user sees. In this example, a catalog filter updates visible cards after the browser receives and runs client JavaScript.

import { test, expect } from "@playwright/test";

test("catalog filter narrows visible courses", async ({ page }) => {
  await page.goto("/courses");

  await page.getByRole("button", { name: "Advanced" }).click();

  await expect(page.getByTestId("course-card")).toHaveCount(2);
  await expect(page.getByRole("heading", { name: "Next.js Full-Stack Development" })).toBeVisible();
  await expect(page.getByRole("heading", { name: "Intro to HTML" })).toBeHidden();
});

The expected behavior is that the filter button becomes clickable, two matching cards remain, and a beginner course is hidden. getByRole checks the accessible surface, while getByTestId is acceptable for repeated structural items where user-facing names would be ambiguous. The test does not care whether filtering is implemented with local state, URL search params, or a server round trip.

Example 3: Verify a Route Handler Through the UI

An end-to-end test becomes more valuable when it covers the seam between UI and server behavior. The next example submits a newsletter form, waits for the route handler response, and verifies that the UI acknowledges success.

import { test, expect } from "@playwright/test";

test("newsletter signup posts email and shows confirmation", async ({ page }) => {
  await page.goto("/newsletter");

  const responsePromise = page.waitForResponse((response) =>
    response.url().endsWith("/api/newsletter") && response.request().method() === "POST"
  );

  await page.getByLabel("Email address").fill("reader@example.com");
  await page.getByRole("button", { name: "Subscribe" }).click();

  const response = await responsePromise;
  expect(response.status()).toBe(201);
  await expect(page.getByText("Check your inbox to confirm your subscription.")).toBeVisible();
});

The deterministic output is an HTTP 201 response and the confirmation message. This test is stronger than checking only the text because it proves the browser actually sent the POST. It is still not a database test unless the route writes to a real test database. For that, create isolated test data and clean it after the run.

Example 4: Control Authentication with Storage State

Most serious Next.js applications have protected routes. Repeating a login form in every test is slow and brittle. Playwright can run a setup project once, store cookies and local storage, and reuse that state in dependent tests.

import { test as setup, expect } from "@playwright/test";

setup("authenticate test user", async ({ page }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill("student@example.com");
  await page.getByLabel("Password").fill("correct horse battery staple");
  await page.getByRole("button", { name: "Sign in" }).click();

  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
  await page.context().storageState({ path: "playwright/.auth/student.json" });
});

The expected artifact is a storage-state JSON file containing the authenticated browser state for a test account. Do not commit real session cookies. In CI, generate this file from seeded credentials or a controlled test identity, and expire or rotate that identity like any other credential.

Design Choices and Trade-offs

Use Playwright for journeys where browser behavior matters: navigation, accessibility roles, responsive layout, forms, file uploads, cookies, redirects, and JavaScript hydration. Use lower-level tests for pure business rules and edge cases that do not need a browser. A suite with fifty browser tests for date formatting will be slow and noisy. A suite with no browser tests can miss broken links, inaccessible buttons, failed middleware redirects, and client-side regressions.

Prefer role and label locators because they align tests with accessible UI. Use data-testid for stable technical anchors when the accessible name is duplicated or intentionally variable. Avoid selectors tied to generated class names or DOM depth. For data, choose between seeded real services, in-memory fakes, and network interception. Real services give confidence but require cleanup. Interception is fast but can drift from the backend contract. A practical suite often uses a small number of full-stack tests plus more UI tests with controlled responses.

Failure Modes and Troubleshooting

Symptom: tests pass locally but time out in CI. Cause: the Next.js server starts more slowly or waits on missing environment variables. Diagnose: inspect Playwright’s web server output, verify baseURL, and run with npx playwright test --trace on. Correct: provide CI environment variables, increase the web server timeout when justified, and make startup fail loudly when configuration is missing.

Symptom: a click fails because the element is not enabled. Cause: the server-rendered button appears before hydration or before required data loads. Diagnose: open the trace viewer and inspect actionability checks. Correct: assert the real readiness signal, such as a loaded option, enabled button, or completed network response, instead of adding arbitrary sleeps.

Symptom: tests influence each other. Cause: shared accounts, shared database rows, or reused browser state. Diagnose: run tests with --repeat-each and in parallel to expose order dependence. Correct: create unique records per test, isolate storage state by role, and delete test data during teardown.

Security, Performance, and Reliability

End-to-end tests can accidentally normalize insecure behavior if they bypass authentication or seed overpowered users. Keep test identities least-privileged and cover forbidden paths: an unauthenticated visitor should be redirected, and a student should not open an instructor route. For performance, keep the browser suite focused. Run smoke tests on every change and broader cross-browser suites before release. For reliability, collect traces on retry, screenshots on failure, and console logs when debugging. Retries should reduce transient noise, not hide deterministic defects.

Hands-on Lab

Prerequisites: a working Next.js app, Node dependencies installed, and a route that renders a visible heading. Install Playwright with npm init playwright@latest if the project does not already contain it. Step 1: add the configuration shown earlier and set baseURL to your local app. Step 2: create tests/e2e/home.spec.ts with a route smoke test. Step 3: add one interaction test for a form, filter, dialog, or protected navigation path. Step 4: run npx playwright test. Step 5: run npx playwright show-report and inspect the report even when tests pass.

Verification is concrete: the report should show the configured projects, each test should have a clear title, and failures should include a trace or screenshot depending on your settings. To clean up, remove any generated test accounts, delete temporary records created by the suite, and remove local storage-state files that contain cookies. If the Playwright setup was experimental, rollback by deleting the Playwright config, test directory, generated auth files, and package scripts you added.

Assessment Exercises

  1. A form submission test passes when checking confirmation text, but the backend returns 500. How would you change the test to catch the defect without asserting private implementation details?
  2. Your team wants every component tested in Playwright. Which cases should stay in unit or component tests, and why?
  3. A protected dashboard test sometimes starts logged out. List three likely causes and the diagnostic artifact you would inspect first.
  4. Rewrite a brittle CSS selector such as .card:nth-child(2) button using Playwright locators that reflect user-visible behavior.
  5. Design a cleanup strategy for tests that create courses in a shared staging database while still allowing parallel execution.

Summary

Playwright tests a Next.js application from the browser inward. It is strongest when the route, UI, network behavior, authentication state, and accessible surface all matter to the result. Configure the server and browser contexts deliberately, write locators around user-visible behavior, keep data isolated, and use traces to diagnose failures. A balanced Next.js test strategy uses Playwright for critical journeys and relies on smaller tests for fast coverage of pure logic.