End-to-End with Playwright

Advanced · 20 min read · ▶ live playground · ✦ checkpoint

Unit tests ask, "Does this small piece behave?" UI tests ask, "Can the user see and use this component?" End-to-end tests ask the bigger question: can a real browser move through a critical path the way a real person would?

Playwright is the tool JavaScript teams often reach for at this layer. It opens browsers, navigates pages, clicks controls, fills fields, waits for UI changes, and reports failures with browser-level evidence.

The E2E layer

In the testing pyramid, end-to-end tests sit near the top. They are valuable because they exercise the whole stack you point them at: built JavaScript, routing, browser APIs, server responses, storage, styles, and the real accessibility tree.

That confidence costs more than a unit test:

  • E2E tests are slower because browsers and servers have to start.
  • Failures can have several causes: app code, test data, network state, timing, or environment.
  • Debugging often needs screenshots, traces, videos, and logs.
  • A big E2E suite can become expensive in CI.

So do not turn every unit test into a browser test. Use Playwright for the paths where confidence is worth the cost: signup, login, checkout, saving important data, permissions, routing, and other flows the business cannot afford to ship broken.

Project shape

A Playwright project usually has its own config, a test folder, and npm scripts:

npm install -D @playwright/test
npx playwright install
{
  "scripts": {
    "dev": "vite",
    "test": "vitest run",
    "e2e": "playwright test",
    "e2e:headed": "playwright test --headed",
    "e2e:report": "playwright show-report"
  }
}

The config can start the local app before tests run:

import { defineConfig, devices } from "@playwright/test";
 
export default defineConfig({
  testDir: "./tests/e2e",
  timeout: 30_000,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: process.env.CI
    ? [["dot"], ["html", { open: "never" }]]
    : [["list"], ["html", { open: "never" }]],
  use: {
    baseURL: "http://127.0.0.1:5173",
    trace: "on-first-retry",
  },
  webServer: {
    command: "npm run dev -- --host 127.0.0.1",
    url: "http://127.0.0.1:5173",
    reuseExistingServer: !process.env.CI,
  },
  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
    { name: "firefox", use: { ...devices["Desktop Firefox"] } },
    { name: "webkit", use: { ...devices["Desktop Safari"] } },
  ],
});

That file says: find tests in tests/e2e, start the Vite dev server, use a base URL, write an HTML report, run in major browser engines, and keep a trace when a retry is needed. The exact config changes by project, but the shape stays recognizable.

A real-browser flow

Playwright tests import test and expect from @playwright/test. The page fixture is a browser page controlled by Playwright:

import { expect, test } from "@playwright/test";
 
test("guest can add an item and finish checkout", async ({ page }) => {
  await page.goto("/shop");
 
  await expect(page.getByRole("heading", { name: "Shop" })).toBeVisible();
 
  await page.getByRole("button", { name: "Add notebook" }).click();
  await expect(page.getByRole("status")).toHaveText("1 item in cart");
 
  await page.getByRole("link", { name: "Checkout" }).click();
  await page.getByLabel("Email").fill("ada@example.com");
  await page.getByRole("button", { name: "Place order" }).click();
 
  await expect(page.getByRole("heading", { name: "Receipt" })).toBeVisible();
  await expect(page.getByText("Order confirmed")).toBeVisible();
});

This is not a unit test for subtotalCents, and it is not a component test for one button. It is a user journey. The test starts at the shop page, adds a product, moves to checkout, enters an email, submits, and verifies the receipt screen.

Run it:

npm run e2e -- --workers=1
 
# Running 3 tests using 1 worker
# ✓ [chromium] › tests/e2e/checkout.spec.ts:3:1 › guest can add an item and finish checkout
# ✓ [firefox]  › tests/e2e/checkout.spec.ts:3:1 › guest can add an item and finish checkout
# ✓ [webkit]   › tests/e2e/checkout.spec.ts:3:1 › guest can add an item and finish checkout
#
# 3 passed

The same test runs against several browser engines because the config defined three projects. Some teams run all browsers in CI and only one locally for speed. That is a workflow choice, not a testing law.

Resilient selectors

Selectors decide whether your E2E test describes user behavior or page implementation. Prefer locators that match how a user or assistive technology understands the page:

await page.getByRole("button", { name: "Place order" }).click();
await page.getByLabel("Email").fill("ada@example.com");
await expect(page.getByText("Order confirmed")).toBeVisible();

These are resilient because they depend on user-facing contracts: roles, accessible names, labels, and visible text.

Implementation-detail selectors are weaker:

await page.locator(".btn-primary").click();
await page.locator("#email-input-173").fill("ada@example.com");
await page.locator("div > section:nth-child(3) > p").isVisible();

Those can break during harmless CSS or markup refactors. Sometimes you need an explicit testing hook; use a stable test id when there is no good user-facing selector:

<button data-testid="place-order-button">Place order</button>
await page.getByTestId("place-order-button").click();

Treat test ids as explicit contracts, not random escape hatches. If a user-facing locator is clear, use it first.

E2E in CI

End-to-end tests are most useful when they run before broken code merges. A GitHub Actions workflow can install dependencies, install Playwright browsers, run the tests, and keep the HTML report as an artifact:

name: Playwright
 
on:
  pull_request:
  push:
    branches: [main]
 
jobs:
  e2e:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npm run e2e
      - uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

The important CI moves are ordinary: install from the lockfile, install the browser binaries and Linux dependencies, run the same script people can run locally, and save a report for failed runs.

Secrets need care. E2E reports, traces, screenshots, and logs can contain user emails, tokens, page content, or test data. Upload artifacts only to places your team trusts, and avoid using real customer data in tests.

Choose the right layer

The testing pyramid is not a command to write fewer useful tests. It is a cost model:

Layer             Best for                         Tradeoff
Unit tests        Rules, calculations, parsing      Fast, narrow confidence
UI tests          Component behavior users see      Medium cost, simulated DOM
E2E tests         Critical full browser journeys    Slower, highest workflow confidence

A healthy project has all three. If a subtotal rule breaks, a unit test should catch it in milliseconds. If a form stops showing validation text, a UI test should catch it. If a checkout route, network response, and receipt page no longer work together, a Playwright test earns its keep.

Section 21 gave you the professional testing stack: unit tests, UI tests, mocks, HTTP boundaries, and real-browser flows. Next, Section 22 moves JavaScript onto the server with Node.js, where the same testing habits follow your code into files, processes, APIs, databases, and deployment.

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion