Playwright as the default web stack
Auto-waiting, tracing, parallel workers, and a debugging story the previous generation never had. Learn it properly — locators, fixtures, projects, the trace viewer — because it is now the reasonable default and the baseline others are judged against.
Playwright is a browser automation library from Microsoft. It drives Chromium, Firefox and WebKit through one API, in TypeScript, Python, Java or C sharp.
It is the reasonable default for new browser automation work, and the reason is not speed or fashion. It is that the two things which historically made browser suites unbearable are handled by the tool rather than by you.
Auto-waiting means a click waits for the element to be actionable, so most timing bugs never exist. Browser contexts give each test an isolated session in milliseconds, so tests do not share cookies or storage.
Those two remove the majority of causes behind a flaky browser suite.
The terms you will hear
- Locator. A lazy, re-evaluated way of finding an element. Not a snapshot of the DOM.
- Web-first assertion.
expect(locator).toHaveText(), which retries until it passes or times out. - Browser context. An isolated session. Fast to create, so one per test is normal.
- Trace. A recording of a run: DOM snapshots, network, console, actions.
- Fixture. Playwright's built-in setup and teardown mechanism.
- Project. A named configuration, typically one per browser or device.
The four features that matter
1. Auto-waiting. page.click() waits for the element to exist, be visible, be stable and be enabled. Most sleep(3) lines in older suites exist to work around the absence of this.
2. Web-first assertions. await expect(total).toHaveText('10.99') retries for a timeout rather than asserting once. This is the single largest source of flake reduction when migrating an old suite.
3. Browser contexts. A fresh, isolated session per test in a few milliseconds. Different logins in the same file, no cookie bleed, safe parallelism, which is determinism handed to you.
4. Trace viewer. On failure, save a trace and open it locally. You get the DOM at each step, the network, the console and the exact action that failed. It removes most of the guesswork from a CI-only failure.
What it does not solve
- Shape. A suite of 200 end-to-end tests is still too slow and too broad, whatever drives the browser. That is the pyramid or trophy decision.
- Architecture. Without fixtures and builders, tests become 40-line setup blocks in a nicer syntax.
- Test data. Shared state still collides. Isolation of the browser is not isolation of the database.
- The suite you already have. Migrating is a real project, and it is worth scoping deliberately, as in large-scale migration.
A worked configuration
For example, here is a small, honest configuration and one test written the way the tool intends.
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests',
// Fail the run if somebody leaves a .only in a test.
forbidOnly: !!process.env.CI,
// One retry in CI, and every retry is visible in the report so
// flakiness cannot hide behind it.
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 4 : undefined,
// Traces only on the first retry: enough to debug, no storage cost
// on green runs.
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'ios', use: { ...devices['iPhone 14'] } },
],
reporter: [['html', { open: 'never' }], ['list']],
})import { test, expect } from './fixtures' // brings the builders with it
test('a gift card covers part of the order, the card pays the rest', async ({
page,
loggedInCustomer,
activeCard,
}) => {
await page.goto('/checkout')
// Role-based, so a redesign does not break it and a missing
// accessible name does.
await page.getByLabel('Gift card code').fill(activeCard.code)
await page.getByRole('button', { name: 'Apply' }).click()
// Web-first assertions: they retry, so no waits are needed anywhere.
await expect(page.getByTestId('gift-card-applied')).toHaveText('25.00')
await expect(page.getByTestId('remaining-to-pay')).toHaveText('10.99')
await expect(page.getByRole('button', { name: /Pay 10\.99/ })).toBeEnabled()
})Notice what is absent. No waits, no sleeps, no reading values into variables, no CSS selectors. That is the tool used as intended, and it is why the test is six lines rather than thirty.
How to show you know it
- A config with retries visible. One retry, traces on retry, and flakiness reported rather than hidden.
- Role-based locators throughout. It shows you know the accessibility side effect is a feature.
- A trace you used. "The CI failure was a stale cache, and the trace showed the 304 response." That is the tool paying for itself.
- An honest boundary. Being able to say what Playwright does not fix is what separates a practitioner from an enthusiast.
Questions
Should we migrate our Selenium suite?
Only if the current suite costs you real time in flakiness and maintenance. Migration is a project, so cull first and migrate what earns its place, rather than moving everything.
Is Cypress still a reasonable choice?
For a team already productive in it, yes. For a new suite, Playwright's multi-browser support, contexts and trace viewer make it the easier default.
How many browsers should we run?
The ones your traffic actually uses, which for most products is Chromium plus WebKit for Safari and one mobile viewport. Adding Firefox because it exists costs runtime for little signal.
Do we still need page objects?
Some structure, yes, though smaller than before. Fixtures and locators absorb much of it, and screen knowledge still belongs in one place, per test architecture.