End-to-end tests have a reputation problem: slow, flaky, and abandoned six months after they were written. Most of that reputation was earned by tooling that made waiting, isolation and debugging the tester’s problem. Playwright — an open-source framework released by Microsoft in January 2020, under the Apache 2.0 license — was designed by the team that previously built Puppeteer at Google, specifically to remove those failure modes from the framework user’s hands.
This article explains precisely what Playwright is and how it works, then builds a complete, runnable test suite: project setup, configuration, real tests against a live application, Page Object Model, custom fixtures, network mocking, and a CI pipeline.
What Playwright is, precisely
Playwright is two things that ship together:
- A browser automation library — a single API that drives the three major browser engines: Chromium (Chrome, Edge), Firefox, and WebKit (the engine behind Safari). The same test runs unchanged on all three.
- A test runner (
@playwright/test) — parallel execution across worker processes, fixtures, retries, projects, an HTML reporter, and a trace viewer for post-mortem debugging.
The library has official bindings for TypeScript/JavaScript, Python, Java and .NET; the full-featured test runner is the Node.js one, and it is what this article uses. Playwright runs on Windows, Linux and macOS, headless or headed, and can emulate mobile browsers (viewport, user agent, touch, geolocation, locale) — emulation of Mobile Safari and Chrome for Android, not real devices.
The architecture: why it is fast and stable
Selenium WebDriver sends one HTTP request per command. Playwright instead holds a single persistent WebSocket connection to each browser and speaks the browser’s native debugging protocol — the Chrome DevTools Protocol for Chromium, and equivalent protocols in patched builds of Firefox and WebKit that Playwright downloads and manages itself. Commands and events flow both ways over that one connection, which eliminates per-command round-trip overhead and lets Playwright observe the browser (network activity, DOM lifecycle, console) rather than poll it.
flowchart LR
subgraph NODE ["Node.js process"]
SPEC["Test files"] --> RUNNER["@playwright/test runner"]
RUNNER --> API["Playwright API"]
end
API -- "persistent WebSocket<br/>CDP" --> CR["Chromium"]
API -- "persistent WebSocket<br/>patched protocol" --> FF["Firefox"]
API -- "persistent WebSocket<br/>patched protocol" --> WK["WebKit"]
subgraph CR ["Chromium"]
CTX1["Browser context A<br/>(test 1 — isolated)"] --> PG1["Page"]
CTX2["Browser context B<br/>(test 2 — isolated)"] --> PG2["Page"]
end
The second architectural pillar is the browser context: an isolated, incognito-like profile — own cookies, storage, cache — created inside an already-running browser in milliseconds. The test runner gives every test a fresh context by default, so tests are fully isolated from each other without paying the multi-second cost of launching a browser per test. That is what makes parallel, isolated E2E execution cheap.
The four features that eliminate flakiness
Auto-waiting. Every action (click, fill, press, …) first performs a series of actionability checks — the element must be visible, stable (not animating), able to receive events (not covered by another element), and enabled — and retries them until they all pass or a timeout expires. There is no sleep(2000) in a well-written Playwright test, because the framework itself waits exactly as long as necessary and not a millisecond longer.
flowchart TD
CALL["await locator.click()"] --> CHECKS{"Actionability checks:<br/>visible · stable · receives events · enabled"}
CHECKS -- "all pass" --> ACT["Perform the click"]
CHECKS -- "not yet" --> WAIT["Wait, then re-check"]
WAIT --> CHECKS
CHECKS -- "timeout exceeded" --> FAIL["TimeoutError with a log of<br/>every check that failed"]
Web-first assertions. expect(locator).toHaveText(...) does not read the DOM once and compare — it re-evaluates the locator until the assertion passes or the assertion timeout (5 seconds by default) expires. Asynchronous UI updates stop being a race you have to win manually.
Strict locators. A Locator is a lazy description of how to find an element, resolved fresh at every use. Playwright’s recommended locators target what a user perceives — getByRole, getByLabel, getByPlaceholder, getByText, getByTestId — rather than CSS or XPath tied to DOM structure. Locators are strict: if a locator resolves to two elements and the action expects one, the test fails immediately with a clear error instead of silently clicking the wrong one.
Tracing. With trace: 'on-first-retry', every failed-then-retried test produces a trace file: a filmstrip of screenshots, a full DOM snapshot before and after every action, network requests, console output and source code — explorable offline in the trace viewer. It turns “flaky on CI, works on my machine” from an archaeology project into a five-minute read.
Where it sits relative to Selenium and Cypress
| Selenium | Cypress | Playwright | |
|---|---|---|---|
| Transport | HTTP request per command (WebDriver) | Runs inside the browser | Persistent WebSocket, native protocols |
| Engines | All major, incl. real Safari | Chromium-family, Firefox; WebKit experimental | Chromium, Firefox, WebKit |
| Multiple tabs / origins in one test | Yes | Restricted | Yes, first-class |
| Auto-waiting | Manual explicit waits | Built-in | Built-in, incl. actionability checks |
| Languages | Many | JavaScript/TypeScript | TS/JS, Python, Java, .NET |
| Parallelism | External grid | Paid orchestration for full features | Built into the runner, free |
Selenium remains the choice when you need real Safari or a vendor-neutral W3C standard; Cypress offers an excellent interactive developer experience inside its constraints. For most new projects testing modern web apps, Playwright’s combination of speed, isolation and debugging tooling is the strongest default — which is why frameworks and platforms increasingly ship Playwright configs out of the box.
A complete sample project
Everything below is runnable as-is. The tests target demo.playwright.dev/todomvc, the TodoMVC instance the Playwright team hosts for exactly this purpose — so you can reproduce every step without standing up an app.
1. Setup
Requires Node.js 18+. One command scaffolds the project and downloads the browser binaries:
npm init playwright@latest
The generated layout:
my-app/
├── package.json
├── playwright.config.ts # runner configuration
├── tests/ # your test files
│ └── example.spec.ts
└── tests-examples/ # a full demo suite you can delete
2. Configuration
playwright.config.ts is the single source of truth for how tests run. A production-ready configuration, annotated:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true, // parallelize tests within a file, not just across files
forbidOnly: !!process.env.CI, // fail CI if a stray test.only slipped in
retries: process.env.CI ? 2 : 0, // retry only on CI, where infra flakiness exists
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'https://demo.playwright.dev',
trace: 'on-first-retry', // capture a full trace when a retry happens
screenshot: 'only-on-failure',
},
// Each project is a browser (or device) the whole suite runs against.
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'mobile', use: { ...devices['iPhone 14'] } },
],
});
Two defaults worth knowing exactly: each test has a 30-second timeout, and each assertion retries for up to 5 seconds. Actions have no timeout of their own by default — they are bounded by the test timeout. All three are configurable globally or per call.
3. First tests
tests/todo.spec.ts:
import { test, expect } from '@playwright/test';
test.describe('TodoMVC', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/todomvc/');
});
test('adds a todo item', async ({ page }) => {
const input = page.getByPlaceholder('What needs to be done?');
await input.fill('Review the Playwright article');
await input.press('Enter');
// Web-first assertions: these retry until they pass or time out.
await expect(page.getByTestId('todo-title')).toHaveText(
'Review the Playwright article'
);
await expect(page.getByTestId('todo-count')).toHaveText('1 item left');
});
test('completes and filters items', async ({ page }) => {
const input = page.getByPlaceholder('What needs to be done?');
for (const item of ['buy milk', 'write tests', 'ship it']) {
await input.fill(item);
await input.press('Enter');
}
// Check the item off by targeting its accessible checkbox.
await page
.getByTestId('todo-item')
.filter({ hasText: 'write tests' })
.getByRole('checkbox')
.check();
await page.getByRole('link', { name: 'Completed' }).click();
await expect(page.getByTestId('todo-title')).toHaveText(['write tests']);
await expect(page).toHaveURL(/#\/completed/);
});
});
Notice what is absent: no waits, no sleeps, no retries in the test body. The page fixture arrives pointing at a brand-new browser context; auto-waiting and retrying assertions absorb every timing concern.
Run it:
npx playwright test # all projects, headless, parallel
npx playwright test --project=chromium # one browser
npx playwright test --ui # interactive UI mode with time-travel
npx playwright test --debug # step through with the inspector
npx playwright show-report # open the HTML report
4. Page Object Model
Once a suite grows, selectors and interaction sequences should live in one place. A page object wraps the locators and the verbs; tests keep only the intent.
tests/pages/todo-page.ts:
import { type Page, type Locator, expect } from '@playwright/test';
export class TodoPage {
readonly input: Locator;
readonly items: Locator;
readonly counter: Locator;
constructor(private readonly page: Page) {
this.input = page.getByPlaceholder('What needs to be done?');
this.items = page.getByTestId('todo-item');
this.counter = page.getByTestId('todo-count');
}
async goto() {
await this.page.goto('/todomvc/');
}
async add(...titles: string[]) {
for (const title of titles) {
await this.input.fill(title);
await this.input.press('Enter');
}
}
async complete(title: string) {
await this.items.filter({ hasText: title }).getByRole('checkbox').check();
}
async expectCount(n: number) {
await expect(this.counter).toHaveText(`${n} item${n === 1 ? '' : 's'} left`);
}
}
5. Custom fixtures: dependency injection for tests
Fixtures are the runner’s composition mechanism — everything a test receives (page, context, browser) is a fixture, and you can define your own. A fixture encapsulates setup and teardown, and is instantiated only for tests that request it:
tests/fixtures.ts:
import { test as base } from '@playwright/test';
import { TodoPage } from './pages/todo-page';
export const test = base.extend<{ todoPage: TodoPage }>({
todoPage: async ({ page }, use) => {
const todoPage = new TodoPage(page);
await todoPage.goto();
await use(todoPage); // everything before use() is setup,
}, // everything after would be teardown
});
export { expect } from '@playwright/test';
Tests now import from ./fixtures and receive a ready-to-use page object:
import { test, expect } from './fixtures';
test('completing an item decrements the counter', async ({ todoPage }) => {
await todoPage.add('one', 'two', 'three');
await todoPage.expectCount(3);
await todoPage.complete('two');
await todoPage.expectCount(2);
});
6. Network mocking
Playwright sits at the network layer, so any request the page makes can be fulfilled, modified or aborted — which turns “the backend is down / slow / returns an edge case” into a deterministic test input rather than an environmental hazard:
test('renders the empty state when the API returns no results', async ({ page }) => {
// Intercept before navigating so the first request is already covered.
await page.route('**/api/todos', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ todos: [] }),
})
);
await page.goto('/app/');
await expect(page.getByText('Nothing to do — enjoy your day')).toBeVisible();
});
test('shows an error banner when the API fails', async ({ page }) => {
await page.route('**/api/todos', (route) => route.abort('connectionrefused'));
await page.goto('/app/');
await expect(page.getByRole('alert')).toContainText('Could not load your todos');
});
The same mechanism records and replays full HAR files (page.routeFromHAR) when you want realistic payloads without a live backend.
7. Continuous integration
Playwright’s browsers install with one command including OS-level dependencies, which makes the CI story short. .github/workflows/e2e.yml:
name: e2e
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 30
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
- uses: actions/upload-artifact@v4
if: $
with:
name: playwright-report
path: playwright-report/
retention-days: 14
The uploaded HTML report embeds the traces of every failed test — download the artifact, open the report, and replay the failure action by action. For large suites, npx playwright test --shard=1/4 splits the run across machines with no extra infrastructure.
8. Two tools worth knowing on day one
- Codegen —
npx playwright codegen demo.playwright.dev/todomvcopens a browser and writes test code (with resilient, role-based locators) as you click. Treat the output as a first draft to refactor into page objects, not as the finished test. - UI mode —
npx playwright test --uiruns the suite in a watch-mode GUI with a time-travel view of every action, live locator picking, and instant re-runs of a single test. It is the fastest inner loop for writing new tests.
Limits, stated plainly
- Playwright drives WebKit, not Safari: the same engine, but not the branded browser with its integration layers. Bugs that live in Safari-the-app can escape it.
- Mobile coverage is emulation, not real handsets. For real-device testing you still need a device cloud.
- Component testing and Electron support exist but are marked experimental.
- E2E tests remain the top of the pyramid: Playwright makes them fast and stable, not free. Keep them for user-critical flows, and keep the bulk of your coverage in unit and integration tests.
Verdict
Playwright’s design premise is that flakiness is a framework defect, not a fact of nature — and the architecture backs the claim: persistent protocol connections, millisecond-cheap isolated contexts, actionability-checked actions, retrying assertions, and traces that make failures self-explaining. The sample above — config, page objects, fixtures, mocking, CI — is genuinely the whole skeleton of a production suite. Start with two or three user-critical flows, wire the trace viewer into CI before you need it, and you will get what E2E testing rarely delivers: tests the team actually trusts.