Skip to main content

Playwright โ€” The Complete Guide

A single-read, end-to-end reference for Playwright: enough to onboard onto a new automation codebase, design a framework from scratch, or walk into an SDET interview. Organized so you can jump to any section as a lookup, or read top-to-bottom as a course.

๐Ÿ“‹ Quick reference: Playwright โ†’ Intermediate
Learn this in 10 minutes10 min
  1. Install, then run your first test and open the HTML report
  2. Learn locators (getByRole, getByText) and why they beat CSS selectors
  3. Understand auto-waiting so you never need sleep()
  4. Read Fixtures and the Page Object Model before growing the suite
After this guide you can
  • Write and run a Playwright test end to end
  • Choose resilient locators and rely on auto-waiting
  • Structure a suite with fixtures and the Page Object Model
  • Mock the network, handle auth, and run in CI

๐Ÿงญ Mental modelHow Playwright executes a testFixtures set up context and feed test code, which asks a locator to find an element, which auto-waits until the element is actionable, then acts on it through a browser engine, which can have its network layer intercepted.Fixturesinject browser / page / contextTest codedescribes intentLocatorfinds the elementAuto-waitretries until actionableBrowserChromium / FirefoxWebKit โ€” via CDPNetwork layerroute() intercepts / mocks

Everything Playwright does routes through one loop: fixtures hand a test its browser context, a locator finds an element by role or text, auto-waiting retries until that element is truly actionable, and only then does the action fire against the real browser engine โ€” with the network layer sitting underneath, ready to intercept or mock any request the page makes.

1. What Playwright Is and Why It Existsโ€‹

Playwright is a Microsoft-built, open-source framework for reliable end-to-end testing of web applications. It automates Chromium, Firefox, and WebKit through a single API, in JavaScript/TypeScript, Python, Java, or .NET.

It was built to fix the pain points of Selenium-era automation:

Old pain (Selenium-era)Playwright's fix
Flaky tests from manual waits/sleepsAuto-waiting built into every action
Slow WebDriver protocol round-tripsTalks to browsers over CDP / native protocols, much faster
Hard to test iframes, shadow DOM, multiple tabsFirst-class support for frames, shadow DOM piercing, multiple contexts/pages
No native network interceptionBuilt-in request/response interception & mocking
Painful debuggingTrace Viewer, Codegen, UI Mode, video/screenshot capture out of the box
One browser engine per driver binaryOne API drives all three engines
Test isolation required manual cleanupBrowser Contexts = cheap, isolated "incognito" sessions per test

Core architectureโ€‹

  • Browser โ€” a single browser process (Chromium/Firefox/WebKit binary Playwright downloads and manages).
  • BrowserContext โ€” an isolated, incognito-like session within a browser (own cookies, storage, cache). Cheap to create โ€” this is Playwright's unit of test isolation.
  • Page โ€” a tab within a context. Most of your API surface (page.click, page.goto, page.locator) hangs off this.
  • Playwright Test Runner (@playwright/test) โ€” the test framework layered on top: fixtures, parallelization, retries, reporters, trace/video capture, assertions (expect).
Playwright process
โ””โ”€ Browser (Chromium)
โ”œโ”€ BrowserContext (test 1) โ€” isolated cookies/storage
โ”‚ โ””โ”€ Page (tab)
โ””โ”€ BrowserContext (test 2) โ€” isolated cookies/storage
โ””โ”€ Page (tab)

Each test gets a fresh BrowserContext by default in @playwright/test, so tests are isolated without needing to clear cookies/localStorage manually.


2. Installation & Project Setupโ€‹

npm init playwright@latest
# scaffolds: playwright.config.ts, tests/, tests-examples/, package.json script, GitHub Actions workflow (optional)

This installs @playwright/test and downloads browser binaries (npx playwright install). Key config file โ€” playwright.config.ts:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
testDir: './tests',
timeout: 30_000,
expect: { timeout: 5_000 },
fullyParallel: true,
forbidOnly: !!process.env.CI, // fail build if `.only` left in code
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: [['html'], ['github']],
use: {
baseURL: 'https://staging.example.com',
trace: 'on-first-retry', // capture trace only when a test is retried
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
],
});

projects is how you fan a single test suite out across browsers, viewports, or device emulations without duplicating test code.


3. Anatomy of a Testโ€‹

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

test.describe('Login flow', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
});

test('valid credentials redirect to dashboard', async ({ page }) => {
await page.getByLabel('Username').fill('abhishek');
await page.getByLabel('Password').fill('secret123');
await page.getByRole('button', { name: 'Sign in' }).click();

await expect(page).toHaveURL(/dashboard/);
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});

test('invalid credentials show an error', async ({ page }) => {
await page.getByLabel('Username').fill('bad');
await page.getByLabel('Password').fill('wrong');
await page.getByRole('button', { name: 'Sign in' }).click();

await expect(page.getByText('Invalid username or password')).toBeVisible();
});
});
  • test, test.describe, test.beforeEach/afterEach, test.beforeAll/afterAll mirror Jest/Mocha conventions.
  • The page fixture is injected automatically โ€” you never instantiate a browser yourself in normal test code.
  • test.only, test.skip, test.fixme, and annotations like { tag: '@smoke' } control selective execution.

4. Locators โ€” the Right Way to Find Elementsโ€‹

Playwright's Locator is a lazy, auto-retrying reference to an element (or set of elements). It does not resolve to a DOM node until an action is performed โ€” this is central to why Playwright avoids "stale element" errors that plague Selenium.

Preferred locator strategies (in priority order)โ€‹

  1. getByRole โ€” matches how assistive tech/users perceive the page. Most resilient to markup changes.
    page.getByRole('button', { name: 'Submit' });
    page.getByRole('checkbox', { name: 'Remember me', checked: true });
  2. getByLabel โ€” form fields by associated <label>.
  3. getByPlaceholder, getByText, getByAltText, getByTitle.
  4. getByTestId โ€” for elements with data-testid, when semantic locators aren't practical. Configurable via testIdAttribute in config.
  5. CSS / XPath (page.locator('css=...'), page.locator('xpath=...')) โ€” last resort; brittle against markup/style refactors.

Chaining and filteringโ€‹

const row = page.getByRole('row', { name: 'Invoice #1042' });
await row.getByRole('button', { name: 'Delete' }).click();

// filter() narrows a locator by another locator or text
page.getByRole('listitem').filter({ hasText: 'Product A' });

// nth / first / last
page.getByRole('listitem').nth(2);

Locators vs. old-style ElementHandlesโ€‹

page.$() / ElementHandle resolve immediately and go stale โ€” avoid them in new code. page.locator() (and getBy*) re-query the DOM on every action, which is what makes Playwright resilient to re-renders (React/Angular/Vue reconciliation, dynamic content).


5. Auto-Waiting & Actionability โ€” Why Playwright Doesn't Need sleep()โ€‹

Before performing any action (click, fill, check, โ€ฆ), Playwright automatically waits for the element to be:

  1. Attached to the DOM
  2. Visible (not display:none, not zero-size)
  3. Stable (not animating โ€” two consecutive frames at the same bounding box)
  4. Enabled (not disabled)
  5. Receiving events (not obscured by another element, e.g. a modal overlay)
  6. For inputs: Editable

This is called an actionability check. If it doesn't pass within the timeout, the test fails with a precise error explaining which check failed โ€” a huge debugging advantage over generic "element not found" errors.

Explicit waiting when you actually need itโ€‹

await page.waitForURL('**/dashboard');
await page.waitForLoadState('networkidle'); // use sparingly โ€” see below
await locator.waitFor({ state: 'visible' });
await page.waitForResponse(resp => resp.url().includes('/api/orders') && resp.status() === 200);
await page.waitForRequest('**/api/session');

Anti-pattern: page.waitForTimeout(3000) (hard sleep). It's the #1 source of flaky and slow suites. Use it only as a last-resort debugging aid, never committed.

networkidle caution: modern apps with polling, websockets, or analytics beacons may never go "idle." Prefer waiting for a specific response or UI state instead of networkidle.


6. Assertions (expect)โ€‹

Playwright's expect is web-first: assertions like toBeVisible() or toHaveText() retry automatically until the timeout, instead of failing on the first check. This eliminates most race-condition flakiness.

await expect(locator).toBeVisible();
await expect(locator).toHaveText('Order confirmed');
await expect(locator).toContainText('confirmed');
await expect(locator).toHaveValue('abhishek@example.com');
await expect(locator).toHaveCount(3);
await expect(locator).toBeEnabled();
await expect(locator).toBeChecked();
await expect(page).toHaveTitle(/Dashboard/);
await expect(page).toHaveURL(/\/orders\/\d+/);
await expect(page).toHaveScreenshot('dashboard.png'); // visual regression

// Soft assertions โ€” collect failures, don't stop the test immediately
expect.soft(locator).toHaveText('X');

// Negation
await expect(locator).not.toBeVisible();

// Custom polling for non-locator conditions
await expect.poll(async () => await getQueueDepth()).toBeLessThan(5);

Never use plain Node assert or manual if checks for UI state โ€” you lose auto-retry and get flaky tests.


7. Fixtures โ€” Playwright's Dependency Injection Systemโ€‹

Fixtures are how Playwright Test provides setup/teardown and shared context to tests, replacing beforeEach-with-shared-variables patterns.

Built-in fixturesโ€‹

page, context, browser, browserName, request (API context).

Custom fixturesโ€‹

// fixtures.ts
import { test as base, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

type MyFixtures = {
loginPage: LoginPage;
authenticatedPage: Page;
};

export const test = base.extend<MyFixtures>({
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await use(loginPage);
},

// Worker-scoped, auto-used, reusable authenticated session
authenticatedPage: async ({ browser }, use) => {
const context = await browser.newContext({ storageState: 'auth/user.json' });
const page = await context.newPage();
await use(page);
await context.close();
},
});

export { expect };
// test file
import { test, expect } from '../fixtures';

test('user can update profile', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/profile');
// already logged in โ€” no login steps needed here
});

Fixtures can be test-scoped (default, fresh per test) or worker-scoped ({ scope: 'worker' }, shared across all tests on a worker โ€” useful for expensive setup like spinning up a test DB connection).


8. Page Object Model (POM)โ€‹

POM encapsulates locators and page-specific actions behind a class, so tests read like business language and locator changes require editing one file.

// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';

export class LoginPage {
readonly page: Page;
readonly username: Locator;
readonly password: Locator;
readonly submit: Locator;

constructor(page: Page) {
this.page = page;
this.username = page.getByLabel('Username');
this.password = page.getByLabel('Password');
this.submit = page.getByRole('button', { name: 'Sign in' });
}

async goto() {
await this.page.goto('/login');
}

async login(user: string, pass: string) {
await this.username.fill(user);
await this.password.fill(pass);
await this.submit.click();
}
}
test('login works', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('abhishek', 'secret123');
await expect(page).toHaveURL(/dashboard/);
});

Modern variant: many teams now prefer combining POM with fixtures (inject page objects as fixtures, as in Section 7) rather than instantiating new LoginPage(page) in every test โ€” less boilerplate, same benefits.


9. Handling Real-World UI Complexityโ€‹

iframesโ€‹

const frame = page.frameLocator('#payment-iframe');
await frame.getByLabel('Card number').fill('4242 4242 4242 4242');

Shadow DOMโ€‹

Playwright pierces open shadow DOM automatically with normal locators โ€” no special syntax needed.

New tabs / popupsโ€‹

const [popup] = await Promise.all([
page.waitForEvent('popup'),
page.getByRole('link', { name: 'Open in new tab' }).click(),
]);
await popup.waitForLoadState();

File upload / downloadโ€‹

await page.getByLabel('Upload file').setInputFiles('data/sample.pdf');

const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByRole('button', { name: 'Export CSV' }).click(),
]);
await download.saveAs('/tmp/export.csv');

Dialogs (alert, confirm, prompt)โ€‹

page.on('dialog', async dialog => {
await dialog.accept();
});

Keyboard / mouseโ€‹

await page.keyboard.press('Enter');
await locator.hover();
await page.mouse.wheel(0, 500);
await locator.dragTo(otherLocator);

10. Network Interception & Mockingโ€‹

This is one of Playwright's biggest advantages over legacy tools โ€” full control over network traffic without a proxy.

// Mock an API response entirely (no backend call made)
await page.route('**/api/orders', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 1, item: 'Widget' }]),
});
});

// Modify a real response (e.g. inject an error to test error handling)
await page.route('**/api/orders', async route => {
const response = await route.fetch();
const json = await response.json();
json.push({ id: 999, item: 'Injected' });
await route.fulfill({ response, json });
});

// Block resources (speed up tests / test offline states)
await page.route('**/*.{png,jpg,jpeg}', route => route.abort());

// Simulate network failure
await page.route('**/api/payment', route => route.abort('failed'));

// Assert on network calls (spy)
const responsePromise = page.waitForResponse('**/api/orders');
await page.getByRole('button', { name: 'Load orders' }).click();
const response = await responsePromise;
expect(response.status()).toBe(200);

Use cases: testing error/empty states without backend cooperation, testing slow-network UX (route.continue() with artificial delay), avoiding flaky third-party dependencies (payment gateways, analytics), and contract-style checks on request payloads.


11. Authentication Strategiesโ€‹

Storage State (recommended) โ€” log in once, reuse the session across all tests to avoid repeating slow UI login:

// global-setup.ts (or a dedicated auth.setup.ts project)
import { chromium } from '@playwright/test';

export default async function globalSetup() {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://app.example.com/login');
await page.getByLabel('Username').fill('abhishek');
await page.getByLabel('Password').fill('secret123');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('**/dashboard');
await page.context().storageState({ path: 'auth/user.json' });
await browser.close();
}
// playwright.config.ts
globalSetup: require.resolve('./global-setup'),
use: { storageState: 'auth/user.json' },

The modern recommended pattern is a setup project (projects: [{ name: 'setup', testMatch: /.*\.setup\.ts/ }]) with dependent test projects declaring dependencies: ['setup'] โ€” this integrates with the test report and retries better than globalSetup.

For multi-role apps (admin/user/guest), create separate storage-state files per role and pick the fixture per test file.


12. API Testingโ€‹

Playwright ships a full HTTP client (request fixture / APIRequestContext), useful for pure API tests or for fast test-data setup that bypasses the UI.

test('create order via API then verify in UI', async ({ request, page }) => {
const response = await request.post('/api/orders', {
data: { item: 'Widget', qty: 3 },
});
expect(response.ok()).toBeTruthy();
const order = await response.json();

await page.goto(`/orders/${order.id}`);
await expect(page.getByText('Widget')).toBeVisible();
});

This is the backbone of hybrid testing: seed/verify state via API (fast, stable), assert user-facing behavior via UI (what actually matters to users).


13. Component Testingโ€‹

Playwright can mount and test individual UI components (React, Vue, Svelte) in a real browser, without a full app shell:

import { test, expect } from '@playwright/experimental-ct-react';
import { Button } from './Button';

test('button click fires handler', async ({ mount }) => {
let clicked = false;
const component = await mount(<Button onClick={() => (clicked = true)}>Go</Button>);
await component.click();
expect(clicked).toBe(true);
});

This sits between unit tests (jsdom, no real browser) and full E2E โ€” real rendering/CSS/events, but fast and isolated from backend dependencies.


14. Parallelization, Sharding & Retriesโ€‹

  • Workers โ€” parallel OS processes running test files concurrently (workers: 4). Tests within one file run sequentially unless fullyParallel: true.
  • Sharding โ€” split the whole suite across CI machines: npx playwright test --shard=1/4, --shard=2/4, etc. Each shard runs a subset; combine reports afterward (merge-reports).
  • Retries โ€” retries: 2 reruns failed tests to absorb transient flakiness, but should be paired with root-causing genuinely flaky tests, not used to mask them permanently.
  • Test isolation โ€” because each test gets its own BrowserContext, tests can safely run in parallel without shared-state collisions, as long as they don't share mutable backend state (e.g. two tests both creating a user named "test").

15. Debugging Toolkitโ€‹

ToolCommandUse for
UI Modenpx playwright test --uiInteractive time-travel debugging, watch mode, pick locators
Trace Viewernpx playwright show-trace trace.zipPost-mortem debugging of CI failures โ€” DOM snapshots, network, console, per-action screenshots
Codegennpx playwright codegen example.comRecord actions, generate locators/code by clicking through the app
Debug modenpx playwright test --debugStep through a test with Playwright Inspector, pause on each action
VS Code extensionโ€”Run/debug individual tests from the editor, set breakpoints
page.pause()inline in codeDrop into Inspector at that exact point

Trace on CI is the highest-leverage debugging setup: trace: 'on-first-retry' captures a full trace only when a test fails and gets retried, giving you a scrubbable timeline without bloating storage for passing runs.


16. Visual Regression Testingโ€‹

await expect(page).toHaveScreenshot('homepage.png', {
maxDiffPixelRatio: 0.02,
mask: [page.locator('.timestamp')], // hide dynamic content
});

Baselines are platform-specific (rendering differs by OS) โ€” generate/update with --update-snapshots, and typically run visual tests in CI on one consistent OS/Docker image to avoid cross-machine diffs.


17. CI Integrationโ€‹

# .github/workflows/playwright.yml
name: Playwright Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
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: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 14

Best practices for CI:

  • forbidOnly: !!process.env.CI so a stray .only never silently skips coverage.
  • Cache ~/.cache/ms-playwright (or use --with-deps fresh) to speed up installs.
  • Upload the HTML report + traces as artifacts โ€” the report is useless if you can't retrieve it after the job ends.
  • Run smoke tests on every PR; full regression on a schedule/merge-to-main, especially for large suites.

18. Framework Design Best Practicesโ€‹

  • Prefer role/label locators over CSS/XPath โ€” survives markup refactors.
  • No hard sleeps. Ever.
  • One assertion concept per test โ€” narrow, fast-failing tests are easier to triage than mega-tests.
  • Independent tests โ€” no test should depend on another test's side effects; use fixtures/API setup instead of UI chains.
  • Tag tests (@smoke, @regression, @critical) and wire tags to CI stages via --grep.
  • Centralize environment config (baseURL, credentials) โ€” never hardcode URLs per test.
  • Fail fast on infra issues โ€” distinguish "app bug" failures from "test environment down" failures in reporting/alerting.
  • Keep POM/fixtures thin โ€” page objects should expose actions and locators, not assertions; assertions belong in tests.
  • Review flaky tests weekly โ€” a suite that's ignored because "it's just flaky" stops providing signal, which defeats the purpose of automation.

19. Playwright vs. Other Tools (Quick Comparison)โ€‹

PlaywrightSeleniumCypress
Browser enginesChromium, Firefox, WebKitChromium, Firefox, WebKit, Safari (via drivers)Chromium-family (+ experimental Firefox/WebKit)
ProtocolCDP / native (fast)WebDriver (HTTP, slower)Runs inside the browser
Auto-waitYes, built-inNo โ€” manual waitsYes, built-in
Multi-tab/windowNative supportClunkyNot supported (single tab)
Network interceptionNativeNeeds proxy toolsNative (cy.intercept)
LanguagesJS/TS, Python, Java, .NETAlmost anyJS/TS only
ParallelizationBuilt-in (workers/sharding)Needs Selenium GridNeeds Cypress Cloud/3rd-party
Test runnerOwn (@playwright/test)Needs external (JUnit/pytest/etc.)Own

20. Interview-Ready Q&Aโ€‹

Q: How does Playwright avoid flaky tests compared to Selenium? A: Auto-waiting/actionability checks before every action, web-first assertions that retry until timeout, and a fast native browser protocol instead of WebDriver's HTTP round-trips โ€” most flakiness in Selenium suites comes from race conditions these features eliminate by design.

Q: What's the difference between page.locator() and page.$()? A: locator() returns a lazy, re-queryable reference re-evaluated on every action (no staleness); $() returns an ElementHandle resolved immediately, which can go stale after DOM re-renders. Always prefer locators.

Q: How do you speed up a suite where every test logs in through the UI? A: Authenticate once in a setup step, persist storageState (cookies + localStorage) to a file, and reuse it via context.storageState โ€” reduces login from every test to once per suite run (or per role).

Q: How would you test that an API failure shows the right error UI, if the backend rarely fails? A: page.route() to intercept the specific endpoint and route.fulfill() with a 500/4xx mock response, or route.abort() to simulate a network failure โ€” no backend cooperation required.

Q: Why use BrowserContext instead of restarting the browser per test? A: Contexts are cheap (milliseconds) and give full isolation โ€” no shared cookies/storage โ€” so you get Selenium-grade isolation without the cost of a fresh browser process per test.

Q: What causes a test to time out even though the element is clearly visible in the screenshot? A: An actionability check other than visibility is failing โ€” commonly an overlay/modal intercepting pointer events, or the element being mid-animation (stability check). The error message names the exact failed check; the trace viewer shows the DOM state at that moment.

Q: How do you structure a suite for both smoke and full regression runs? A: Tag tests (test('...', { tag: '@smoke' }, ...)), then run npx playwright test --grep @smoke on every PR and the full suite on a merge/ nightly schedule โ€” keeps PR feedback fast while preserving full coverage cadence.


Practical exercises
  1. In a project with @playwright/test installed (npm i -D @playwright/test and npx playwright install chromium), save this as tests/save.spec.ts. The status message appears one second after you click:

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

    test('saving shows a confirmation without any sleep', async ({ page }) => {
    await page.setContent(`
    <label for="name">Name</label>
    <input id="name">
    <button>Save</button>
    <p role="status"></p>
    <script>
    document.querySelector('button').onclick = () => {
    setTimeout(() => {
    document.querySelector('[role=status]').textContent = 'Saved';
    }, 1000);
    };
    </script>`);

    await page.getByLabel('Name').fill('Ada');
    await page.getByRole('button', { name: 'Save' }).click();

    await expect(page.getByRole('status')).toHaveText('Saved');
    });

    Run it with npx playwright test. Then change the delay in the script from 1000 to 7000 and run it again.

    Done when: the first run passes with no waitForTimeout, and the second fails with a message naming the locator (getByRole('status')), the expected and received text, and Timeout: 5000ms.

    Stretch: Raise the delay to 7000 and read the failure, then give just that assertion a longer timeout with the timeout option.

  2. Serve both the page and the API from page.route so no backend is needed, and test the success and failure paths:

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

    const html = `
    <ul id="orders"></ul>
    <p role="alert"></p>
    <script>
    fetch('/api/orders')
    .then(r => { if (!r.ok) throw new Error('bad'); return r.json(); })
    .then(orders => {
    const ul = document.getElementById('orders');
    orders.forEach(o => {
    const li = document.createElement('li');
    li.textContent = o.item;
    ul.appendChild(li);
    });
    })
    .catch(() => {
    document.querySelector('[role=alert]').textContent = 'Could not load orders';
    });
    </script>`;

    test.beforeEach(async ({ page }) => {
    await page.route('https://shop.test/', route =>
    route.fulfill({ contentType: 'text/html', body: html }));
    });

    test('lists the mocked orders', async ({ page }) => {
    await page.route('**/api/orders', route =>
    route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify([{ id: 1, item: 'Widget' }, { id: 2, item: 'Gadget' }]),
    }));
    await page.goto('https://shop.test/');
    await expect(page.getByRole('listitem')).toHaveCount(2);
    });

    test('shows an error when the API fails', async ({ page }) => {
    await page.route('**/api/orders', route => route.fulfill({ status: 500, body: 'boom' }));
    await page.goto('https://shop.test/');
    await expect(page.getByRole('alert')).toHaveText('Could not load orders');
    });

    Done when: both tests pass without touching a real server, and you can explain why the second test exercises an error path that would be hard to trigger against a healthy backend.

The page that never went idle
Context

Illustrative scenario. A team makes every test wait for the network to go idle after each navigation, believing it is the safest possible wait. The app polls a status endpoint and sends analytics beacons in the background.

What happened

The network never reached idle on some pages, so those tests sat until the timeout and failed for no functional reason, while other pages passed. The wait looked safe but depended on background traffic the tests did not control.

Lesson

Modern apps with polling, websockets, or beacons may never go idle. Wait for something specific instead, such as a particular response or a visible piece of UI, and let web-first assertions retry until it appears.

Use this with AI
  • Ask an assistant to convert a CSS or XPath-based test to getByRole and getByLabel locators, then run it and confirm each locator resolves to exactly one element, because Playwright errors when an action matches several.
  • Paste a failing test's error output and ask which actionability check failed (attached, visible, stable, enabled, receiving events), then confirm by opening the trace yourself.
  • Have it write page.route mocks for an API's success and failure responses, and check that the mocked shapes match a real response by comparing against a live one.

21. One-Line Summaryโ€‹

Playwright wins by removing the two biggest sources of E2E pain โ€” flaky waits and slow/limited browser control โ€” with auto-waiting actionability checks, a fast native protocol, and first-class network/context/debugging tooling built directly into the test runner.