Skip to content
End To End Tester

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.

typescript
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:

typescript
// 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

typescript
// 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.

typescript
// 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

typescript
// 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

bash
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 shards

Full 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 ↗

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?