Skip to content
C Codeloom
Testing

End-to-End Testing with Playwright

A practical guide to end-to-end testing with Playwright, covering installation, writing your first test, selectors, auto-wait, and running the suite in CI.

·6 min read · By Yash Kesharwani
Intermediate 11 min read

What you'll learn

  • How to install and configure Playwright in a new project
  • How to write your first end-to-end test against a running app
  • How Playwright selectors and auto-wait remove most flakiness
  • How to run the suite reliably in CI
  • When end-to-end coverage is worth the cost versus unit tests

Prerequisites

  • A grasp of [what testing is](/blog/what-is-testing)
  • Familiarity with [vitest basics](/blog/vitest-basics)
  • Comfort with running a local web app

End-to-end tests drive your application the way a real user does. They open a browser, click buttons, fill out forms, and assert on what appears on the screen. They catch the bugs that unit tests cannot see because they live in the integration of code, browser behaviour, network conditions, and rendering. Playwright has become the default tool for this layer because it is fast, reliable, and pleasant to write against.

Installing Playwright

Playwright ships with a scaffolder that installs the runner and downloads the browser binaries.

npm init playwright@latest

The wizard asks whether you want TypeScript, where to put your tests, whether to add a GitHub Actions workflow, and whether to install all three browsers. For most projects, accept the defaults. The result is a playwright.config.ts, an example test, and a downloaded copy of Chromium, Firefox, and WebKit.

A typical config looks like this.

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

export default defineConfig({
  testDir: "./tests/e2e",
  timeout: 30_000,
  expect: { timeout: 5_000 },
  retries: process.env.CI ? 2 : 0,
  reporter: process.env.CI ? "github" : "list",
  use: {
    baseURL: "http://localhost:3000",
    trace: "on-first-retry",
    screenshot: "only-on-failure",
  },
  webServer: {
    command: "npm run dev",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
  },
  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
    { name: "firefox", use: { ...devices["Desktop Firefox"] } },
  ],
});

The webServer block tells Playwright to boot your app if it is not already running. That single feature removes the most common source of CI flakiness, which is a race between the test runner and the server.

Your first test

A Playwright test reads close to a script of what a person would do.

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

test("user can sign up and see the dashboard", async ({ page }) => {
  await page.goto("/signup");

  await page.getByLabel("Email").fill("ada@example.com");
  await page.getByLabel("Password").fill("supersecret123");
  await page.getByRole("button", { name: "Create account" }).click();

  await expect(page).toHaveURL("/dashboard");
  await expect(page.getByRole("heading", { name: "Welcome, ada" })).toBeVisible();
});

Notice how each interaction reads as a sentence describing user intent. There is no manual waiting, no sleeps, and no explicit polling. Playwright’s expect calls and locator actions wait for the right conditions automatically.

Run it with:

npx playwright test

Add --ui for an interactive runner that lets you step through actions, or --headed if you want to watch the browser do its thing.

Selectors that survive a redesign

Playwright recommends user-facing locators in this order of preference:

  • getByRole for buttons, headings, links, and form fields. These map directly to accessibility roles, which means they double as a check that your UI is reachable for assistive technology.
  • getByLabel for form inputs by their associated label.
  • getByText for content readers actually see.
  • getByTestId for cases where the above are not stable, against a data-testid attribute you control.

CSS and XPath selectors still work but should be a last resort. They couple your tests to implementation details that are likely to change.

Auto-wait explained

The largest source of flakiness in older browser test tools was the manual sleeps developers added to give the page time to settle. Playwright eliminates almost all of these.

When you call await page.click("button"), Playwright actually performs a sequence of checks before issuing the click: the element must be attached to the DOM, visible, stable, receive events, and enabled. It retries the lookup until those conditions hold or the configured timeout expires.

The same logic applies to expect. await expect(locator).toHaveText("Saved") will keep checking the text until the assertion passes or the expectation timeout fires. That means you almost never write waitForTimeout in real test code; if you find yourself reaching for it, there is usually a stronger assertion you could write instead.

Network and state control

Playwright lets you stub network calls when you want to isolate the front-end from a flaky upstream.

test("shows an error toast when the API fails", async ({ page }) => {
  await page.route("**/api/users", route => route.fulfill({
    status: 500,
    body: JSON.stringify({ error: "boom" }),
  }));

  await page.goto("/users");
  await expect(page.getByRole("alert")).toHaveText(/something went wrong/i);
});

You can also restore application state quickly through storage, bypassing slow login flows.

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

setup("authenticate", async ({ page }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill("ada@example.com");
  await page.getByLabel("Password").fill("supersecret123");
  await page.getByRole("button", { name: "Sign in" }).click();
  await page.context().storageState({ path: ".auth/user.json" });
});

Other tests load that state and skip the login screen entirely, which keeps total run time bounded as the suite grows.

CI integration

Running Playwright in CI is straightforward.

name: e2e
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
      - if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7

Three small habits matter. Install browser dependencies with --with-deps so the runner has the system libraries it needs. Upload the report on failure so you can debug without re-running the suite locally. Set retries: 2 in the config for CI only, so genuine flakes do not block merges while real failures still surface.

E2E versus unit tests

End-to-end tests are powerful but slow. A single signup flow that runs in 4 seconds is fine; a suite of 200 of them is not. Reserve E2E coverage for journeys that users actually take and that combine multiple subsystems: signing up, paying, checking out, sending a message.

Use unit tests for the parsing logic, validators, formatters, and reducers underneath. Use integration tests, possibly with mocks, for the seams between modules. Use E2E only when the answer to “does the whole thing actually work for a user” is what you need.

Wrap up

Playwright makes browser testing as approachable as any other test layer. Install it, write tests that read like user stories, prefer accessibility-driven locators, and let auto-wait do the work that sleeps used to. Wire it into CI with retries and artifacts, then keep the suite small and journey-focused. Combined with unit tests built on tools like vitest, you get high confidence without a brittle, slow pipeline.