Skip to content

Playwright complete notes: from beginner to advanced

Updated 2026-09-28
14
chapters
3
levels
1.63
checked against

Playwright is Microsoft's end-to-end testing framework for Chromium, Firefox and WebKit. Learn it in this order: install and run, understand Browser → Context → Page, locate by role and label, trust auto-waiting and web-first assertions, handle frames, popups and downloads, mock the network and test APIs, organise with fixtures and projects, and debug with traces.

These notes take you from zero to owning a Playwright suite in fourteen short chapters. Each one starts with a plain-English answer, shows current code (checked against Playwright 1.63), flags the mistakes that make tests flaky, and ends with a quick self-check. Mark chapters as done — your progress is saved in this browser.

Key takeaways

  • Locators by role and label plus auto-waiting do most of the work of keeping Playwright tests stable.
  • Every test gets its own BrowserContext — isolation is free, so never share state between tests.
  • Start listening for popups, downloads and responses before the click that triggers them.
  • Use the API for setup, mocks for edge cases, and the UI only for the behaviour under test.
  • Debug with traces, not screenshots: trace: 'on-first-retry' in the config.

How Playwright runs your test

From a line of TypeScript to a real browser engine and back.

  1. Your testTypeScript calls the Playwright API: page.getByRole(…).click().
  2. Playwright libraryTurns the call into a protocol command and handles auto-waiting.
  3. One persistent connectionCommands and events share a single pipe or WebSocket — no HTTP per step.
  4. Browser engineChromium, Firefox or WebKit performs the action on a real page.
  5. Result back to the testEvents and results stream back; the assertion passes or retries.

The course

Filter by level, open the self-checks, and mark chapters done. Everything stays on this page — no signup.

Chapters0/14
  1. 01What Playwright is — and why teams pick it
  2. 02Architecture: how a command reaches the browser
  3. 03Installation & project setup
  4. 04Browser, BrowserContext & Page
  5. 05Locators: find elements the way users do
  6. 06Auto-waiting & web-first assertions
  7. 07Actions & input methods
  8. 08Frames, tabs, dialogs, uploads & downloads
  9. 09Waiting — the right way
  10. 10API testing & network interception
  11. 11Fixtures, hooks, parallelism & projects
  12. 12Reporting & debugging
  13. 13What's new in 2026 (Playwright 1.56 → 1.63)
  14. 14Interview questions & final review
01Beginner

What Playwright is — and why teams pick it

Playwright is Microsoft's open-source framework for end-to-end testing of web apps. One API drives Chromium, Firefox and WebKit, waits for elements automatically, and ships its own test runner, reporter, trace viewer and code generator.

  • Cross-browser: Chromium (Chrome, Edge), Firefox and WebKit (Safari's engine) — on Windows, macOS and Linux.
  • Auto-waiting: actions wait until an element is ready; assertions retry until they pass.
  • Isolation: every test gets a fresh browser context — its own cookies and storage — in milliseconds.
  • Batteries included: parallel runs, retries, HTML report, traces, codegen, UI mode, API testing and network mocking.
  • Languages: TypeScript/JavaScript (primary, gets features first), Python, Java and .NET.
WhoWhy they use it
Manual QA moving to automationCodegen and readable locators make the first tests quick to write.
Automation engineersFewer flaky waits, one API for three engines, built-in parallelism.
SDETsFixtures, projects and traces scale to large suites and CI.
DevelopersFast local feedback in UI mode, component and API tests in the same runner.
Check yourself
Which three browser engines does Playwright drive?

Chromium, Firefox and WebKit. Chrome and Edge run on Chromium; WebKit is the engine behind Safari.

Which language gets new Playwright features first?

TypeScript/JavaScript — the Node.js package is the primary target; the Python, Java and .NET bindings follow.

02Beginner

Architecture: how a command reaches the browser

Your test calls the Playwright API; Playwright sends each command over one persistent connection to the browser and receives events back on the same channel. There is no WebDriver server and no HTTP request per command — that is a large part of why Playwright is fast.

  • Chromium is driven over the Chrome DevTools Protocol; Firefox and WebKit are Playwright-patched builds with their own protocols.
  • Python, Java and .NET clients talk to a bundled Node.js driver over a pipe; the driver talks to the browsers.
  • Because the connection is bidirectional, Playwright hears about network requests, dialogs, console messages and navigations the moment they happen.
  • Playwright does not use Selenium or WebDriver — it is a separate stack end to end.

Tip: Locally there is nothing extra to start: Playwright launches the browsers itself and talks to them over a pipe. A separate browser server is only involved in remote setups — browserType.launchServer() or a cloud grid you connect to.

Check yourself
Why is Playwright usually faster than Selenium WebDriver?

It keeps one persistent, bidirectional connection to the browser instead of sending an HTTP request per command, and it hears about navigations, network traffic and dialogs the moment they happen.

03Beginner

Installation & project setup

You need a current LTS version of Node.js. The official initializer asks four questions — TypeScript or JavaScript, the tests folder, whether to add a GitHub Actions workflow, and whether to install browsers — and generates a working project.

PathWhat it holds
tests/Test files (*.spec.ts)
playwright.config.tsHow tests run: browsers, timeouts, reporters, baseURL
playwright-report/Generated HTML report
test-results/Screenshots, videos and traces from the last run
package.json@playwright/test as a dev dependency, plus your scripts
Terminal
npm init playwright@latest

# or add to an existing project
npm install -D @playwright/test
npx playwright install

# run the example tests and open the report
npx playwright test
npx playwright show-report
playwright.config.tsTypeScript
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',                 // where tests live
  timeout: 30_000,                    // per test
  expect: { timeout: 5_000 },         // per assertion
  use: {
    baseURL: 'http://localhost:3000', // so page.goto('/login') works
    trace: 'on-first-retry',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
  ],
});

Tip: Tests run headless by default and print results in the terminal. Add --headed to watch the browsers, or --ui for the interactive runner with time travel.

Check yourself
What does npx playwright install do?

Downloads the browser builds Playwright is tested against (Chromium, Firefox, WebKit). Add --with-deps on CI images to install the OS libraries they need too.

04Beginner

Browser, BrowserContext & Page

A Browser is a running engine. A BrowserContext is an isolated, incognito-like session inside it with its own cookies, storage and permissions. A Page is a single tab inside a context. The test runner creates a fresh context and page for every test, so you rarely create them yourself.

  • Analogy: the browser is a building, contexts are separate flats, pages are rooms in a flat.
BrowserBrowserContextPage
What it isA browser instanceAn isolated sessionOne tab
Created bychromium.launch()browser.newContext()context.newPage()
CostHeavy (seconds)Cheap (milliseconds)Cheap
Isolation—Cookies, storage, cache, permissionsShares its context's state
Typical scopeOne per workerOne per test / per userOne per tab
two users in one testTypeScript
test('admin sees the user’s message', async ({ browser }) => {
  const adminContext = await browser.newContext({ storageState: 'playwright/.auth/admin.json' });
  const userContext = await browser.newContext({ storageState: 'playwright/.auth/user.json' });
  const admin = await adminContext.newPage();
  const user = await userContext.newPage();

  await user.goto('/chat');
  await user.getByRole('textbox').fill('Hello admin');
  await user.keyboard.press('Enter');

  await admin.goto('/chat');
  await expect(admin.getByText('Hello admin')).toBeVisible();

  await adminContext.close();
  await userContext.close();
});
Check yourself
How do you simulate two logged-in users at once?

Create two browser contexts (each with its own storageState) from the same browser — they don't share cookies or storage.

05Beginner

Locators: find elements the way users do

A locator describes how to find an element and is re-evaluated every time you use it, so it survives re-renders. Prefer locators based on what the user sees — role, label, text — over CSS classes and XPath, which break when markup changes.

PriorityLocatorUse for
1getByRole('button', { name: 'Log in' })Buttons, links, headings, inputs — anything with a role
2getByLabel('Email')Form fields with a <label>
3getByPlaceholder('Search')Inputs without a label
4getByText('Welcome back')Non-interactive text
5getByAltText / getByTitleImages, elements with a title
6getByTestId('checkout-total')When nothing user-facing is stable
7locator('css') / locator('xpath=…')Last resort
TypeScript
await page.getByRole('textbox', { name: 'Email' }).fill('qa@example.com');
await page.getByLabel('Password').fill(process.env.PASSWORD!);
await page.getByRole('checkbox', { name: 'Remember me' }).check();
await page.getByRole('button', { name: 'Sign in' }).click();

// Narrow down instead of using indexes
const row = page.getByRole('row').filter({ hasText: 'Ada Lovelace' });
await row.getByRole('button', { name: 'Edit' }).click();

Watch out: getByText matches a case-insensitive substring and normalises whitespace — pass { exact: true } for an exact match. On lists that can reorder, filter by text or by a child rather than picking by index with .nth().

Check yourself
Why is getByRole preferred over a CSS selector?

It targets what users and assistive technology see (role + accessible name), so it survives class and markup changes and doubles as a light accessibility check.

What happens if a locator matches three elements and you call click()?

The test fails with a strict-mode violation. Make the locator more specific (filter, chain, name) rather than reaching for first().

06Beginner

Auto-waiting & web-first assertions

Before an action, Playwright waits until the element is actionable. For a click that means visible, stable (not animating), receiving events (nothing covering it) and enabled; for fill() it means visible, enabled and editable. Assertions made with expect(locator) retry until they pass or time out. Together they remove almost every sleep and explicit wait.

AssertionChecks
toBeVisible() / toBeHidden()Visibility
toHaveText() / toContainText()Exact / partial text
toHaveValue()Input value
toHaveAttribute() / toHaveClass()Attributes and classes
toBeEnabled() / toBeDisabled() / toBeChecked()State
toHaveCount()Number of matches
expect(page).toHaveURL() / toHaveTitle()Page URL and title
TypeScript
await page.getByRole('button', { name: 'Login' }).click();
// no waits: both assertions retry until true (5 s by default)
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByText('Welcome, QA')).toBeVisible();

Watch out: Auto-waiting covers actions and expect(locator / page) assertions. A value you read yourself is read once: write await expect(locator).toHaveText('x'), which retries, rather than expect(await locator.textContent()).toBe('x'), which doesn't.

Check yourself
What does Playwright wait for before a click?

The element must be visible, stable (not animating), receiving events (not covered by another element) and enabled. fill() waits for visible, enabled and editable instead.

07Beginner

Actions & input methods

Actions are methods on a locator: click, fill, check, selectOption, hover, press, setInputFiles, dragTo. Each one auto-waits for the element to be actionable. Pick the method that matches what the user does — fill for typing a value, selectOption for a native select.

MethodUse it for
fill('text')Set an input's value instantly (clears first). The default.
pressSequentially('text')Type key by key — autocomplete, input masks
clear()Empty an input
press('Enter')One key or shortcut
check() / uncheck() / setChecked()Checkboxes and radios
selectOption('UA')Native <select> — don't click options
hover() / dblclick() / click({ button: 'right' })Pointer actions
dragTo(target)Drag and drop
setInputFiles('file.pdf')File inputs
selectText()Select the text inside an input
TypeScript
await page.getByPlaceholder('Full name').fill('Ada Lovelace');
await page.getByLabel('Country').selectOption('UA');
await page.getByLabel('City').pressSequentially('Kyi', { delay: 50 });
await page.getByRole('option', { name: 'Kyiv' }).click();
await page.getByLabel('I accept the terms').check();
await page.getByLabel('CV').setInputFiles('fixtures/cv.pdf');
await page.locator('#card-7').dragTo(page.locator('#done'));

Watch out: Each action returns a Promise, so await them one by one: await field.fill('admin'); then await field.press('Tab'). Use { force: true } only when you know why an element isn't actionable — usually an overlay is covering it for real users too.

Check yourself
fill() or pressSequentially() — which should be the default?

fill(). It is faster and reliable; use pressSequentially() only when the page reacts to individual keystrokes.

08Intermediate

Frames, tabs, dialogs, uploads & downloads

Real apps embed payment iframes, open links in new tabs, pop confirm dialogs and export files. Playwright handles each with a small pattern — and the rule for tabs, downloads and dialogs is the same: start listening before the click that triggers them.

  • Multiple tabs: context.pages() lists every open page in the context.
  • Upload: locator.setInputFiles(path | path[]); pass [] to clear.
  • Without a dialog handler Playwright dismisses dialogs automatically — so an unexpected confirm() won't hang the test.
iframesTypeScript
const payment = page.frameLocator('iframe[title="Payment"]');
await payment.getByLabel('Card number').fill('4242 4242 4242 4242');
new tab / popupTypeScript
const popupPromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'Terms' }).click();
const popup = await popupPromise;
await expect(popup).toHaveURL(/terms/);
dialogsTypeScript
page.once('dialog', async (dialog) => {
  expect(dialog.message()).toContain('Delete this item?');
  await dialog.accept();          // or dialog.dismiss(), or accept('text') for prompts
});
await page.getByRole('button', { name: 'Delete' }).click();
downloadsTypeScript
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Download report' }).click();
const download = await downloadPromise;
await download.saveAs(`downloads/${download.suggestedFilename()}`);

Tip: One pattern covers popups, downloads, file choosers and responses: create the waitForEvent promise, click, then await the promise.

Check yourself
Why must waitForEvent('popup') be called before the click?

The popup event can fire before the click's promise resolves; if you start listening afterwards you can miss it and wait until the timeout.

09Intermediate

Waiting — the right way

Actions and assertions already wait, so explicit waits are only for the few cases an assertion can't express — a URL change, a network response, or an element that must disappear.

NeedUse
Element appears / changesawait expect(locator).toBeVisible() / toHaveText()
Element disappearsawait expect(spinner).toBeHidden() or locator.waitFor({ state: 'hidden' })
URL changes after a clickawait page.waitForURL('**/next') or expect(page).toHaveURL()
A specific API call finishedpage.waitForResponse('**/api/orders') — started before the click
Adjust patience{ timeout } on one assertion, or expect.timeout in config
TypeScript
const saved = page.waitForResponse((r) => r.url().includes('/api/profile') && r.request().method() === 'PUT');
await page.getByRole('button', { name: 'Save' }).click();
expect((await saved).ok()).toBe(true);
await expect(page.getByRole('status')).toHaveText('Saved');

Tip: Timeouts are limits, not waits: setDefaultTimeout() and { timeout } only change how long Playwright keeps trying before it fails. To know a page is ready, assert on the element you need — pages with polling or analytics never go network-idle.

Check yourself
How do you wait for the URL to change after a click?

Click, then await page.waitForURL(pattern) — or assert with await expect(page).toHaveURL(pattern), which retries until the URL matches.

10Intermediate

API testing & network interception

The request fixture sends HTTP calls without a browser — ideal for API tests and for fast setup. page.route() intercepts the browser's own traffic so you can mock responses, patch real ones, change requests or block resources.

  • Test error states cheaply: fulfill with status 500 and check the UI's error message.
  • Use API calls to create and delete test data; keep the UI for the behaviour under test.
API testTypeScript
test('creates a user', async ({ request }) => {
  const response = await request.post('/api/users', { data: { name: 'Ada', job: 'QA' } });
  await expect(response).toBeOK();
  const user = await response.json();
  expect(user).toMatchObject({ name: 'Ada' });
});
authenticated API clientTypeScript
const api = await playwright.request.newContext({
  baseURL: 'https://api.example.com',
  extraHTTPHeaders: { Authorization: `Bearer ${process.env.API_TOKEN}` },
});
mock / patch / modifyTypeScript
// Mock: never hit the server
await page.route('**/api/users', (route) =>
  route.fulfill({ status: 200, json: { data: [{ id: 1, name: 'Mock User' }] } }),
);

// Patch: real response, one field changed
await page.route('**/api/flags', async (route) => {
  const response = await route.fetch();
  const json = await response.json();
  await route.fulfill({ response, json: { ...json, newCheckout: true } });
});

// Modify the request
await page.route('**/api/search**', (route) => {
  const url = new URL(route.request().url());
  url.searchParams.set('limit', '5');
  return route.continue({ url: url.toString() });
});
Check yourself
route.fulfill() vs route.continue() vs route.fetch()?

fulfill answers the request yourself (mock); continue sends it on, optionally modified; fetch performs it and returns the real response so you can patch and then fulfill.

11Advanced

Fixtures, hooks, parallelism & projects

Fixtures are Playwright's dependency injection: a test lists what it needs ({ page, loginPage, apiClient }) and gets it, with setup and teardown handled for it. Hooks still exist for simple cases. Tests run in parallel worker processes, and projects run the same tests against different browsers or settings.

  • Workers: set workers in the config or --workers=4; fullyParallel: true also parallelises tests inside one file.
  • Projects: the same suite on Chromium, Firefox and WebKit, or on mobile viewports, or with a setup project that logs in first.
  • Sharding: --shard=1/4 splits the suite across CI machines.
HookRunsScope
beforeAll / afterAllOnce per worker process (again after a worker restarts on failure)Worker
beforeEach / afterEachAround every testTest
fixtures/test.tsTypeScript
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/login.page';

export const test = base.extend<{ loginPage: LoginPage }>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page)); // code after use() is teardown
  },
});
export { expect } from '@playwright/test';
hooksTypeScript
test.beforeEach(async ({ page }) => {
  await page.goto('/');
});

test.afterEach(async ({ page }, testInfo) => {
  if (testInfo.status !== testInfo.expectedStatus) console.log(`Failed on ${page.url()}`);
});

Watch out: beforeAll runs once per worker process — with 4 workers, 4 times, and again if a worker restarts after a failure. For one-time global work such as seeding data or logging in, use a setup project with dependencies, or globalSetup.

Check yourself
How is a fixture's teardown written?

Anything after await use(value) runs after the test finishes — close connections, delete data, and so on.

12Advanced

Reporting & debugging

Playwright's best debugging tool is the trace: a recording of every action with DOM snapshots, network, console and source you can scrub through. Pair it with the HTML report, UI mode for local work and the Inspector for stepping line by line.

ToolCommandBest for
HTML reportnpx playwright show-reportWhat failed, with screenshots and traces attached
Trace viewernpx playwright show-trace trace.zipWhy it failed — DOM, network and console at each step
UI modenpx playwright test --uiLocal development: watch mode, time travel, pick locators
Inspectornpx playwright test --debugStepping through a test line by line
Pauseawait page.pause()Stop at one line and explore
Codegennpx playwright codegen <url>Recording a first draft and finding locators
playwright.config.tsTypeScript
use: {
  trace: 'on-first-retry',          // or 'retain-on-failure'
  screenshot: 'only-on-failure',
  video: 'retain-on-failure',
},
reporter: [['html'], ['list']],   // add 'junit' or 'allure-playwright' for CI dashboards
Check yourself
Which is more useful for a CI failure: a video or a trace?

The trace. It shows the DOM, network, console and the exact locator at every step; a video only shows pixels.

13Advanced

What's new in 2026 (Playwright 1.56 → 1.63)

The last year of releases pushed Playwright toward AI agents and toward stricter control of flaky tests. You don't need all of it on day one, but you should know it exists.

FeatureVersionWhy it matters
Test Agents: planner, generator, healer1.56npx playwright init-agents sets up AI agents that plan, write and repair tests
Agent-friendly CLI: --debug=cli, npx playwright trace1.59Coding agents can debug and read traces from the terminal
Playwright MCP bundled (npx playwright mcp)1.62Connect an AI assistant to a real browser without extra packages
retryStrategy: 'isolated'1.62Failed tests are retried at the end of the run instead of as soon as a worker is free
Test locks: { lock: 'name' }1.63Tests sharing a lock never run at the same time
locator.visible()1.63Replaces the :visible CSS pseudo-class
frameLocator() with no selector1.63Search across every frame in the page
TypeScript
test('changes global settings', { lock: 'user-settings' }, async ({ page }) => {
  await page.goto('/settings');
  await page.getByLabel('Language').selectOption('uk');
  await expect(page.getByRole('heading', { level: 1 })).toHaveText('Налаштування');
});
Check yourself
What do the three Playwright Test Agents do?

The planner explores the app and writes a Markdown test plan, the generator turns the plan into tests, and the healer runs failing tests and proposes fixes.

14Advanced

Interview questions & final review

If you can answer these without notes, you know Playwright well enough for most QA automation interviews — and for owning a real suite.

  • Write clean tests: one behaviour per test, independent data, no order dependency.
  • Trust auto-waiting; delete sleeps.
  • Keep page objects thin and assertions in tests.
  • Next: build the framework from the scalable-framework guide and practise on the Playwright Sandbox.
QuestionShort answer
Why Playwright over Selenium?Auto-waiting, one fast connection per browser, isolated contexts, built-in runner, traces and API testing.
What is a BrowserContext?An isolated session with its own cookies, storage and permissions; one per test by default.
What is auto-waiting?Actions wait for elements to be actionable; web-first assertions retry until they pass.
Locator priority?getByRole → getByLabel → getByPlaceholder → getByText → getByTestId → CSS/XPath.
How does parallel execution work?Tests are spread over worker processes; each worker has its own browser; projects and shards multiply it.
What is a fixture?Injected, reusable setup/teardown a test declares in its signature — page, request, or your own.
How do you avoid logging in in every test?A setup project saves storageState once; tests load it via use.storageState.
How do you debug a CI-only failure?Enable trace on retry, download the report artifact and open the trace.
Check yourself
A test passes locally and fails in CI 1 time in 10. What is your first step?

Open the trace from the failed attempt (trace on first retry) and compare it with a passing one — look for a race: an assertion on stale data, a missing wait for a response, or shared test data.

FAQ

A few days to write useful tests if you know basic JavaScript or TypeScript; two to four weeks alongside work to be comfortable with fixtures, network mocking and CI; a couple of months to design a framework for a team.

Sources

Related tools

Related guides