Playwright
The default browser automation tool in 2026 — auto-waiting locators, tracing, sharding and fixtures — with the configuration that matters and the mistakes that still cause flakiness.
2 min read · updated 19 September 2026
Playwright drives Chromium, Firefox and WebKit through one API, with auto-waiting built into the primitives rather than bolted on. For a new browser suite it is the default choice, and most of this site's browser examples assume it.
#The two ideas that matter
Locators are lazy. page.getByRole('button', { name: 'Save' }) does not
find anything. It describes how to find something, and resolves at the
moment of use — every time. There is no stale element reference in
Playwright because there is no element handle being held.
Assertions retry. expect(locator).toBeVisible() polls until the
condition holds or the timeout expires. This is what removes the
sleep-shaped flakiness that dominates other suites.
import { test, expect } from '@playwright/test';
test('a customer can check out', async ({ page }) => {
await page.goto('/products/field-notes');
await page.getByRole('button', { name: 'Add to cart' }).click();
await page.getByRole('link', { name: 'Checkout' }).click();
await page.getByLabel('Card number').fill('4242424242424242');
await page.getByRole('button', { name: /^Pay/ }).click();
// Retries until true or times out — no waits, no sleeps.
await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
});The common mistake that reintroduces flakiness:
// Reads once, right now. A race, dressed as an assertion.
expect(await page.getByRole('status').isVisible()).toBe(true);
// Retries. Use this form.
await expect(page.getByRole('status')).toBeVisible();#Configuration worth having
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI, // a stray .only must fail the build
retries: process.env.CI ? 1 : 0, // one retry, reported as "flaky"
workers: process.env.CI ? 4 : undefined,
reporter: [['html', { open: 'never' }], ['list'], ['blob']],
use: {
baseURL: process.env.BASE_URL ?? 'http://127.0.0.1:3000',
trace: 'on-first-retry', // the single best setting here
screenshot: 'only-on-failure',
video: 'retain-on-failure'
},
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: '.auth/user.json' },
dependencies: ['setup']
},
{ name: 'webkit', use: { ...devices['Desktop Safari'] }, dependencies: ['setup'] }
],
// Starts the app for you and waits for it — no sleep in a CI script.
webServer: {
command: 'npm run start',
url: 'http://127.0.0.1:3000/api/health',
reuseExistingServer: !process.env.CI
}
});trace: 'on-first-retry' costs nothing on a green run and turns a CI
failure from a mystery into a five-minute diagnosis. See the
trace viewer.
#Fixtures
Playwright's fixture system is its most underused feature. A fixture is set up on demand, torn down automatically, and can be scoped per test or per worker.
// e2e/fixtures.ts — a logged-in page and a fresh customer, per test
import { test as base } from '@playwright/test';
import { createCustomer, deleteCustomer } from './support/api';
export const test = base.extend<{ customer: Customer }>({
customer: async ({ request }, use, testInfo) => {
const customer = await createCustomer(request, {
email: `buyer-${testInfo.workerIndex}-${Date.now()}@example.test`
});
await use(customer);
await deleteCustomer(request, customer.id);
}
});
export { expect } from '@playwright/test';Worker-scoped fixtures are how you start one database container per worker — see parallel test execution.
#Network control
// Make a third party fail on demand, which the real one will not do.
await page.route('**/api/rates/**', (route) =>
route.fulfill({ status: 503, body: 'unavailable' })
);
await page.goto('/checkout');
await expect(page.getByRole('alert')).toContainText('cannot calculate shipping');Route interception also lets you cut the network entirely for speed, and
assert on outbound requests. Combined with request — Playwright's HTTP
client, which shares the browser's cookies — it covers both
API testing and setup-through-the-API.
#Component testing
@playwright/experimental-ct-react (and the Vue and Svelte equivalents)
mount a single component in a real browser rather than jsdom. Real layout,
real CSS, real event handling. It is slower than
Testing Library in jsdom and catches a
different class of bug — anything involving actual rendering.
#Running it in CI
npx playwright install --with-deps chromium webkit # browsers + OS libs
npx playwright test --shard=1/4 # one of four machines
npx playwright merge-reports --reporter=html ./blob # one report from all shardsFull pipelines in GitHub Actions and sharding.
#Where it is not the answer
- Non-web applications. Desktop, iOS, Android and Flutter all need their own tooling.
- Real devices. WebKit is not Safari-on-an-iPhone, and Chromium is not Chrome-on-a-Pixel. For the last mile, a device cloud — often driven through Selenium or Appium.
- Unit testing. Playwright is not a unit runner. Use Vitest or Jest for that; a browser is four orders of magnitude too expensive for testing a pricing rule.
Common questions
- Is Playwright better than Selenium?
- For a new web test suite, almost always yes — auto-waiting, tracing and built-in parallelism remove most of the code and most of the flakiness. Selenium still wins where you need a W3C-standard protocol, an existing grid, real device clouds or language bindings Playwright does not have.
- Does Playwright test real Safari?
- It tests WebKit, the engine behind Safari, built from the same upstream source. That catches engine-level differences but not Safari-specific UI behaviour, and it is not Safari on an actual iPhone. For the last mile you still need a device cloud.
- What makes Playwright tests less flaky?
- Locators are lazy and re-resolved on every use, and assertions retry until they pass or time out. Together those remove the two biggest sources of browser flakiness — stale element references and asserting on a value read once.
Runnable samples for this page
last test results ↗- TypeScript (Playwright)
browser/tests/tools/playwright
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- Selenium WebDriverThe W3C standard for browser automation — where it still wins, the waiting model that decides whether a suite is stable, and Grid for scale.
- CypressIn-browser test execution, automatic retry-ability and time-travel debugging — what Cypress's architecture buys, and the constraints that come with it.
- End-to-End TestingWhat belongs in an end-to-end suite and what does not, how many journeys are enough, and the practices that keep a browser suite from becoming the thing everyone ignores.
- Playwright ShardingSplitting a browser suite across machines and merging the reports back — the mechanics, the prerequisites, and how to choose a shard count that is actually faster.
- Traces and Debugging CI FailuresA trace records every action, request, console message and DOM snapshot of a run — the single artefact that turns an unreproducible CI failure into a five-minute diagnosis.
- The Page Object ModelEncapsulating a screen behind a class so tests describe intent rather than selectors — what it fixes, the god-object failure mode, and modern alternatives.
- Flaky TestsWhy tests fail intermittently, the six root causes and how to fix each one, how to detect flakiness deliberately, and what to do with a test you cannot fix today.
- End-to-End Code CoverageInstrument the application, run Playwright or Cypress against it, and merge the result with the unit run — the measurement that shows which code only your slowest tests protect.