Skip to content

Playwright

the tool that drives a browser in tests

What it is

Playwright is an open-source framework from Microsoft for automating web browsers, with one programming interface for Chromium, Firefox and WebKit. Playwright Test, published on npm as @playwright/test, is the test runner built on top of it, and it is what most people mean when they use the name. The same project ships the plain library for scripts that are not tests, a command-line tool and an MCP server aimed at AI agents, and an extension for Visual Studio Code, all under the Apache License 2.0.

Beyond JavaScript and TypeScript, the same browser control is available from Python, where the project recommends its pytest plugin, from Java, with any test framework such as JUnit, and from .NET, with base classes for MSTest, NUnit and xUnit.

npx playwright install downloads its own builds of the three engines, and because each release moves those versions forward, the command may need to be run again after an upgrade. It can also drive Google Chrome and Microsoft Edge where they are already on the machine. By default tests run headless, without a visible window, and in parallel across all three engines. Each test gets a fresh browser context, the equivalent of a new browser profile, so one test’s cookies and storage do not carry into the next.

A test reads like the steps a person would take:

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

test("signing in opens the inbox", async ({ page }) => {
    await page.goto("https://example.com/sign-in");
    await page.getByLabel("Email").fill("reader@example.com");
    await page.getByRole("button", { name: "Sign in" }).click();
    await expect(page.getByRole("heading", { name: "Inbox" })).toBeVisible();
});

Two mechanisms carry most of the weight. Before an action, Playwright runs actionability checks and waits until all of them pass. For a click, the locator must resolve to exactly one element, and that element must be visible, stable, which means its bounding box stays the same for at least two animation frames in a row, able to receive the click rather than sitting under another element, and enabled. If the checks do not pass within the timeout, the action ends in a TimeoutError. Assertions written as await expect(locator) behave the same way: they fetch the element again and again until the condition holds or the assertion timeout, five seconds by default, runs out.

Why it matters

The decision Playwright serves is which behaviour has to be checked in a real browser, and how to make those checks trustworthy enough that a red build is believed. Some faults exist only once the page is assembled: a dialog that covers the button it should reveal, a layout that hides a field at one width, a script that works in one engine and throws in another. Tests that run pieces of code in isolation cannot see them.

Browser tests turn flaky when the test and the page race each other. The page fetches data, animates and re-renders, and a test that clicks the moment its line runs sometimes clicks too early. The usual remedy was a fixed pause, too short on a slow machine and wasted time on a fast one. Auto-waiting and retrying assertions replace the pause with the condition the test actually depends on. The assertions guide puts it plainly: web pages show information asynchronously, and assertions that check only once lead to flaky tests.

The second choice is how a test finds things. The locators guide recommends user-facing attributes first, and role locators above all, because getByRole with an accessible name is the closest a test gets to “how users and assistive technology perceive the page”. That has a side effect worth having: a test that clicks the button named “Sign in” breaks if the button loses its name, which is the same defect a screen reader user would meet. The guide also says role locators “do not replace accessibility audits and conformance tests”. Test ids are the fallback. The guide calls them the most resilient option and also “not user facing”, so a suite that finds everything by id proves less about what a visitor experiences. CSS and XPath selectors are not recommended, because the DOM changes often.

The cost is time. Starting a browser and loading a page makes browser tests the slowest kind a project has. When one breaks on a build server, the trace viewer is the way back in. The default configuration records a trace on a failed test’s first retry, with retries set to two on CI and zero locally, and the trace steps through each action with the page before and after it, the network requests and the console.

What it looks like in practice

One platform here keeps its business logic in the database, and the work was to build a layered test suite across four layers. Its stated task was to check each layer’s behaviour where that behaviour actually lives, “rather than everything being checked from the outside through a browser.” The Go API carries 981 test functions. The database carries 494 behavioural test files, so a stored function is tested in the database instead of through three layers of application above it. The frontend carries 543 Vitest and Playwright specs, including 48 end-to-end files and 30 browser specs; the page gives one count for both tools. The whole suite runs as 10 jobs in a 612-line workflow, and the page is direct about the price: the suite is slow, it taxes every change, and at this size it needs maintenance of its own.

This site drives its own browser checks with a different library, Puppeteer, and its credits page shows the same trade-off. Those checks cover what a validator cannot, such as measured contrast, focus order, target size, viewport overflow and print pagination. They take about twenty minutes, so they run on demand rather than on every commit: the gate holds the fast half, and a person runs the slow half.

Where it goes wrong

Most Playwright trouble comes from tests that check less than their names claim.

A fixed pause stands in for a condition. A test waits two seconds and then reads the page, or reads a value once and compares it with a generic assertion. On a busy build server two seconds is sometimes too short. Assert on what a visitor would see with a web-first assertion such as toHaveText, and let it retry.

Elements are found by their markup. A selector through three nested div elements or a styling class breaks on the next redesign while the page still works. Find the element by role and accessible name, or by label or text, and add a test id only where nothing a user perceives identifies it.

A passing role locator is taken as an accessibility check. getByRole proves that one element has the role and name the test asked for. It says nothing about contrast, focus order or the rest of the page. For that, run a rule engine such as axe from the test, as Playwright’s own guide does through @axe-core/playwright; the scan covers the page “in its current state when you call it”, so open the menu or the dialog first.

A click is forced through. The force option turns off the non-essential actionability checks; for a click, Playwright stops checking that the target actually receives the event. A test that needs it is often working around something a visitor would also meet, such as a banner over the button. Treat the original error as a finding, and either dismiss the banner the way a visitor would or fix the overlap.

A flaky test is retried until it passes. With retries on, a test that fails on the first run and passes on a retry is marked flaky rather than failed. A test that is flaky week after week is either racing the page or catching a real intermittent fault, and the trace recorded on its first retry is where to start looking.

Everything is checked through the browser. A rule about prices or permissions, tested by clicking through three screens, is slow and breaks for reasons unrelated to the rule. Test the rule where it lives, and keep browser tests for what only a browser can show: the journey, the layout and the differences between engines.

Further reading

  • Installation — installing Playwright Test, running the example tests, the HTML report and UI Mode.
  • Locators — the recommended order for finding elements, from role and label to test ids.
  • Auto-waiting — the actionability checks behind each action and what force turns off.
  • Assertions — retrying and non-retrying assertions, the default timeout, soft assertions and polling.
  • Trace viewer — recording a trace on retry and stepping through a failed test action by action.
  • Browsers — installing the engines, running branded Chrome and Edge, and the two headless modes.

Glossary