Playwright complete notes: from beginner to advanced
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.
- Your testTypeScript calls the Playwright API: page.getByRole(…).click().
- Playwright libraryTurns the call into a protocol command and handles auto-waiting.
- One persistent connectionCommands and events share a single pipe or WebSocket — no HTTP per step.
- Browser engineChromium, Firefox or WebKit performs the action on a real page.
- 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
- 01What Playwright is — and why teams pick it
- 02Architecture: how a command reaches the browser
- 03Installation & project setup
- 04Browser, BrowserContext & Page
- 05Locators: find elements the way users do
- 06Auto-waiting & web-first assertions
- 07Actions & input methods
- 08Frames, tabs, dialogs, uploads & downloads
- 09Waiting — the right way
- 10API testing & network interception
- 11Fixtures, hooks, parallelism & projects
- 12Reporting & debugging
- 13What's new in 2026 (Playwright 1.56 → 1.63)
- 14Interview questions & final review
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.
| Who | Why they use it |
|---|---|
| Manual QA moving to automation | Codegen and readable locators make the first tests quick to write. |
| Automation engineers | Fewer flaky waits, one API for three engines, built-in parallelism. |
| SDETs | Fixtures, projects and traces scale to large suites and CI. |
| Developers | Fast local feedback in UI mode, component and API tests in the same runner. |
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.
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.
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.
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.
| Path | What it holds |
|---|---|
| tests/ | Test files (*.spec.ts) |
| playwright.config.ts | How 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 |
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-reportimport { 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.
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.
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.
| Browser | BrowserContext | Page | |
|---|---|---|---|
| What it is | A browser instance | An isolated session | One tab |
| Created by | chromium.launch() | browser.newContext() | context.newPage() |
| Cost | Heavy (seconds) | Cheap (milliseconds) | Cheap |
| Isolation | — | Cookies, storage, cache, permissions | Shares its context's state |
| Typical scope | One per worker | One per test / per user | One per tab |
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();
});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.
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.
| Priority | Locator | Use for |
|---|---|---|
| 1 | getByRole('button', { name: 'Log in' }) | Buttons, links, headings, inputs — anything with a role |
| 2 | getByLabel('Email') | Form fields with a <label> |
| 3 | getByPlaceholder('Search') | Inputs without a label |
| 4 | getByText('Welcome back') | Non-interactive text |
| 5 | getByAltText / getByTitle | Images, elements with a title |
| 6 | getByTestId('checkout-total') | When nothing user-facing is stable |
| 7 | locator('css') / locator('xpath=…') | Last resort |
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().
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().
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.
| Assertion | Checks |
|---|---|
| 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 |
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.
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.
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.
| Method | Use 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 |
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.
fill() or pressSequentially() — which should be the default?
fill(). It is faster and reliable; use pressSequentially() only when the page reacts to individual keystrokes.
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.
const payment = page.frameLocator('iframe[title="Payment"]');
await payment.getByLabel('Card number').fill('4242 4242 4242 4242');const popupPromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'Terms' }).click();
const popup = await popupPromise;
await expect(popup).toHaveURL(/terms/);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();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.
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.
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.
| Need | Use |
|---|---|
| Element appears / changes | await expect(locator).toBeVisible() / toHaveText() |
| Element disappears | await expect(spinner).toBeHidden() or locator.waitFor({ state: 'hidden' }) |
| URL changes after a click | await page.waitForURL('**/next') or expect(page).toHaveURL() |
| A specific API call finished | page.waitForResponse('**/api/orders') — started before the click |
| Adjust patience | { timeout } on one assertion, or expect.timeout in config |
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.
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.
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.
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' });
});const api = await playwright.request.newContext({
baseURL: 'https://api.example.com',
extraHTTPHeaders: { Authorization: `Bearer ${process.env.API_TOKEN}` },
});// 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() });
});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.
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.
| Hook | Runs | Scope |
|---|---|---|
| beforeAll / afterAll | Once per worker process (again after a worker restarts on failure) | Worker |
| beforeEach / afterEach | Around every test | Test |
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';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.
How is a fixture's teardown written?
Anything after await use(value) runs after the test finishes — close connections, delete data, and so on.
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.
| Tool | Command | Best for |
|---|---|---|
| HTML report | npx playwright show-report | What failed, with screenshots and traces attached |
| Trace viewer | npx playwright show-trace trace.zip | Why it failed — DOM, network and console at each step |
| UI mode | npx playwright test --ui | Local development: watch mode, time travel, pick locators |
| Inspector | npx playwright test --debug | Stepping through a test line by line |
| Pause | await page.pause() | Stop at one line and explore |
| Codegen | npx playwright codegen <url> | Recording a first draft and finding locators |
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 dashboardsWhich 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.
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.
| Feature | Version | Why it matters |
|---|---|---|
| Test Agents: planner, generator, healer | 1.56 | npx playwright init-agents sets up AI agents that plan, write and repair tests |
| Agent-friendly CLI: --debug=cli, npx playwright trace | 1.59 | Coding agents can debug and read traces from the terminal |
| Playwright MCP bundled (npx playwright mcp) | 1.62 | Connect an AI assistant to a real browser without extra packages |
| retryStrategy: 'isolated' | 1.62 | Failed tests are retried at the end of the run instead of as soon as a worker is free |
| Test locks: { lock: 'name' } | 1.63 | Tests sharing a lock never run at the same time |
| locator.visible() | 1.63 | Replaces the :visible CSS pseudo-class |
| frameLocator() with no selector | 1.63 | Search across every frame in the page |
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('Налаштування');
});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.
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.
| Question | Short 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. |
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
How to Build a Scalable Playwright Framework in TypeScript (2026)
A step-by-step blueprint for a maintainable Playwright + TypeScript framework: folder structure you can click through, config, page objects, fixtures, test data, API setup, storageState auth and a sharded GitHub Actions pipeline.
Playwright Cheat Sheet 2026: Commands, Locators & Assertions (TS + Python)
A searchable Playwright quick reference with 134 copy-ready snippets in 26 sections — install, test structure, locators, actions, assertions, waits, frames, dialogs, popups, files, storage, API testing, emulation, traces, config, annotations and the CLI — in TypeScript and Python, for Playwright 1.63.
Playwright storageState: Log In Once, Reuse It in Every Test (2026)
Stop logging in before every Playwright test. Save the authenticated browser state once with storageState, reuse it across the suite, and see exactly how much CI time it saves with an interactive calculator.