End-to-End Testing

Playwright

Playwright is cross-browser end-to-end testing with auto-waiting, powerful locators and tooling for tracing, codegen and visual comparisons — all in one package.

intermediate14 min readUpdated Sep 15, 2026
login.spec.ts
ts
// tests/login.spec.ts
import { test, expect } from "@playwright/test";

test("user can sign in", async ({ page }) => {
  await page.goto("/login");

  await page.getByLabel("Email").fill("[email protected]");
  await page.getByLabel("Password").fill("secret");
  await page.getByRole("button", { name: "Sign in" }).click();

  await expect(
    page.getByRole("heading", { name: "Dashboard" }),
  ).toBeVisible();
});
Maintained by
Microsoft
Browsers
Chromium, Firefox, WebKit
Locators
Role, label, text, test id
Waiting
Automatic
Tooling
Codegen, trace viewer, UI mode
Parallelism
Workers and sharding

Why it matters

Why Playwright is the modern e2e default

One API, three engines

The same test runs in Chromium, Firefox and WebKit, so you catch browser-specific bugs before users do.

Auto-waiting

Locators wait for elements to be actionable, which removes almost all arbitrary timeouts and flakiness.

Debugging tools

Codegen records tests, the trace viewer replays a run frame by frame, and UI mode makes debugging interactive.

The big picture

The three ideas behind Playwright

Real browsers driven by locators, automatic waiting, and first-class tooling for debugging and scaling.

Locators

Find

Query elements by role, label, text and test id, with waiting built into every action.

Assertions

Verify

Web-first assertions that retry until the condition is true or the timeout expires.

Fixtures and projects

Scale

Share setup through fixtures and run the same tests across browsers and shards.

Playwright at a glance

The core of Playwright

page.goto

Navigate to a URL and start a test.

Locators

getByRole, getByLabel, getByText and getByTestId find elements reliably.

Web-first assertions

expect(locator).toBeVisible() retries until it passes.

Actions

click, fill, selectOption and keyboard interactions on real elements.

Network control

Mock, intercept and assert on requests and responses.

Trace viewer

Record and replay a run with screenshots, network and console.

A short history

From Puppeteer to a cross-browser platform

  1. 2020

    Playwright released

    A team from Puppeteer ships a cross-browser automation library.

    20
  2. 2021

    Playwright Test

    A first-party test runner adds fixtures, parallelism and the trace viewer.

    21
  3. 2022

    Codegen and UI mode

    Recording tests and interactive debugging become core features.

    22
  4. 2024

    Component testing and more

    Experimental component testing and richer tooling expand the scope.

    24
  5. Today

    The e2e default

    Widely adopted for reliable, fast and debuggable end-to-end suites.

    Today

The complete guide

Playwright: Everything you need to know

What is Playwright?

Playwright is a cross-browser end-to-end testing framework from Microsoft. It drives Chromium, Firefox and WebKit through a single API, waits automatically for elements to be ready, and ships a test runner with fixtures, parallelism, tracing and network control.

It grew out of Puppeteer and kept the good parts — a modern protocol and a clean API — while adding true cross-browser support and first-class tooling. For teams starting an end-to-end suite today, it is the most common default, and its auto-waiting is the single biggest reason its tests are less flaky than older tools.

Writing a test

A test navigates, interacts and asserts. Playwright provides the test and expect functions and a page fixture.

// tests/login.spec.ts
import { test, expect } from "@playwright/test";

test("user can sign in", async ({ page }) => {
  await page.goto("/login");

  await page.getByLabel("Email").fill("[email protected]");
  await page.getByLabel("Password").fill("secret");
  await page.getByRole("button", { name: "Sign in" }).click();

  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
});

The page fixture is a browser tab. Actions like click and fill wait for the element to be actionable before acting, so there is no sleep to insert and no race to lose.

Locators and auto-waiting

Locators are lazy queries that resolve to an element when used, and they retry until the element is ready. Prefer user-facing locators in this order:

  1. getByRole — buttons, links, headings and inputs by accessible role and name.
  2. getByLabel — form controls by label.
  3. getByText — visible text.
  4. getByPlaceholder, getByAltText, getByTitle — other visible attributes.
  5. getByTestId — a stable test id when nothing user-facing fits.
// locators.ts
page.getByRole("link", { name: "Pricing" });
page.getByLabel("Search");
page.getByText("Welcome back");
page.getByTestId("cart-total");

Because actions auto-wait, you rarely need explicit waits. Assertions are also web-first: expect(locator).toBeVisible() retries until the condition holds or the timeout expires, which makes tests resilient to timing.

// assertions.ts
await expect(page.getByRole("alert")).toHaveText("Saved");
await expect(page.getByRole("button", { name: "Save" })).toBeEnabled();
await expect(page.getByTestId("row")).toHaveCount(3);

Fixtures and shared setup

Playwright’s fixture system is how you share setup without repeating it. A fixture is a value injected into tests, created and torn down by the runner.

// fixtures.ts
import { test as base } from "@playwright/test";

export const test = base.extend({
  authenticatedPage: async ({ page }, use) => {
    await page.goto("/login");
    await page.getByLabel("Email").fill("[email protected]");
    await page.getByLabel("Password").fill("secret");
    await page.getByRole("button", { name: "Sign in" }).click();
    await use(page);
  },
});

For authentication specifically, the recommended pattern is a setup project that logs in once, saves storage state to a file, and reuses it across tests. That keeps the suite fast and avoids repeating the login flow.

Network control

page.route intercepts requests, so you can test loading, error and edge cases without a real backend.

// mock.ts
await page.route("**/api/users", (route) =>
  route.fulfill({
    status: 500,
    body: JSON.stringify({ message: "Server error" }),
  }),
);

await page.goto("/users");
await expect(page.getByRole("alert")).toContainText("went wrong");

You can also assert on what the app sent, delay responses to test loading states, or block third-party scripts to keep tests fast and deterministic.

Debugging and tooling

Playwright’s tooling is a large part of its appeal:

  • Codegen records your interactions and generates a test file with locators.
  • UI mode runs tests in a watch UI where you can step through actions and inspect the DOM.
  • Trace viewer records a full trace — screenshots, DOM snapshots, network and console — that you can replay after a CI failure.
  • Reporters produce HTML, JUnit and other reports out of the box.

The trace viewer in particular turns a mysterious CI failure into a step-by-step replay, which is often the difference between a five-minute fix and an afternoon of guessing.

Parallelism and CI

Playwright Test runs test files in parallel across worker processes by default. You can run the same suite across browser projects, tune the worker count and shard a large suite across machines with --shard. To parallelise safely, tests must be independent and avoid shared mutable state.

In CI, install the browsers with npx playwright install --with-deps, run the suite and upload the HTML report and traces as artifacts. Retries are available for genuinely flaky infrastructure, but they should be a safety net, not a substitute for fixing flakiness.

Where Playwright fits

End-to-end tests are the top of the pyramid: few, slow and high-value. Below them sit component tests with Testing Library and fast unit tests with Vitest or Jest. Playwright covers the journeys that only a real browser can verify — authentication, checkout, navigation and cross-browser behaviour.

Best practices

  • Prefer role and label locators; use test ids sparingly.
  • Rely on auto-waiting instead of waitForTimeout.
  • Keep tests independent so they parallelise safely.
  • Reuse authentication through storage state rather than logging in each time.
  • Mock the network for edge cases; keep a few tests against the real backend.
  • Record traces on failure and upload them in CI.
  • Use data-testid only for elements with no accessible identity.

Common mistakes

  • Using CSS or XPath selectors tied to styling and structure.
  • Hard waits with waitForTimeout that slow the suite and hide races.
  • Sharing state between tests and breaking parallelism.
  • Testing every edge case end-to-end instead of pushing them down the pyramid.
  • Ignoring traces and guessing at CI failures.
  • Relying on retries to paper over genuine flakiness.

Where to go next

Playwright is the strongest default for end-to-end testing today. Compare it with Cypress for the alternative developer experience, and build the lower layers with Vitest and Testing Library. Then add a single critical user journey as your first e2e test and grow from there.

Locating elements

Role-based locators match how users and assistive technology find elements, and they fail when markup is inaccessible.

Prefer
await page
  .getByRole("button", { name: "Save" })
  .click();
Avoid
await page
  .locator(".btn.btn-primary > span")
  .click();

Waiting for elements

Locators auto-wait for actionability. Hard waits are the number one cause of flaky e2e tests.

Prefer
await expect(
  page.getByText("Saved"),
).toBeVisible();
Avoid
await page.waitForTimeout(3000);
const text = await page
  .locator(".toast").textContent();
expect(text).toBe("Saved");

FAQ

Frequently asked questions

Keep learning

Related topics from the roadmap.

$ start learning

Ready to start learning Playwright?

Our interactive tutorial walks you through Playwright step by step — with quizzes and real code you can run in the browser.