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 →
🧭 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).
  • Retriesretries: 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 extensionRun/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.


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.