Learn Playwright like you had an expert sitting next to you — except this expert never leaves.
This isn't a list of links. It's a complete, sequenced curriculum — concepts, runnable code, common mistakes, quizzes, and a cheat sheet — built so you can go from "never written a test" to "shipping a real automation suite" without needing anyone else in the room.
How this platform works
Follow the sidebar in order
Each module builds on the last. If you already know a topic, skip ahead using the sidebar — every lesson is self-contained enough to make sense on its own.
Type out the code, don't just read it
Open a terminal alongside this page. Every code block here is real, runnable Playwright code — typing it yourself is where the learning actually happens.
Watch for the colored callout boxes
Cyan = tips. Amber = gotchas. Coral = common mistakes people make at your stage. Lime = exercises to try yourself.
Take the quiz at the end of each module
Four to five questions per module check whether the concept actually stuck before you move on. Your progress and quiz scores are saved automatically in this artifact.
You need a computer with internet access and 20–30 minutes free right now to do the Module 2 setup. You do not need prior coding experience to start, but basic comfort with JavaScript syntax (variables, functions) will help — Module 2 and 3 will get you comfortable fast either way.
Welcome & How This Course Works
You're about to learn Playwright the way you'd learn it from a senior engineer pairing with you — except this "engineer" is a static page, so the responsibility for making it stick is entirely on how you use it. Here's the operating manual.
The three rules that make self-taught courses actually work
- Type the code, never copy-paste it. Typing forces your brain to notice syntax, autocomplete suggestions, and errors as they happen. Copy-pasting feels productive and teaches almost nothing.
- Run every example before moving to the next lesson. Reading code and running code produce completely different mental models. Playwright especially needs to be seen — watch the browser open, click, and assert in real time.
- Break things on purpose. Change a selector so it fails. Misspell a method. Read the error message Playwright gives you — they are unusually good, and learning to read them is half of test automation skill.
What you need before Module 2
- A computer (Windows, macOS, or Linux all work identically well).
- A code editor — VS Code is recommended; Playwright has an official VS Code extension you'll use constantly.
- About 20 minutes of uninterrupted time to do the Module 2 install — it's quick, but you don't want to context-switch mid-install.
- Not required: prior testing experience, a CS degree, or deep JavaScript mastery. If you can read an if-statement, you can start.
If you genuinely have ~3 hours: do Modules 1–6 in one sitting (the fundamentals), then Modules 7–10 in a second sitting once you've slept on it. Spaced repetition beats marathon cramming for anything procedural — and test automation is heavily procedural muscle memory.
How to use the building blocks on this page
| Element | What it means |
|---|---|
code blocks | Real, runnable Playwright code. Copy the filename comment too — it tells you where to save it. |
| Cyan tip box | A shortcut, mental model, or "good to know" that isn't strictly required but will save you time. |
| Amber gotcha box | A subtlety that trips up almost everyone once. Read these twice. |
| Coral mistake box | A specific anti-pattern beginners write, why it fails, and the fix. |
| Lime exercise box | A task for you to complete with no code given — this is where real learning happens. |
When you get stuck (because you will)
This course can't answer follow-up questions, so build the habit now of self-resolving blockers the way professionals do:
- Read the actual error message top to bottom before searching anything — Playwright's errors usually name the exact element and reason it failed.
- Check the official docs at playwright.dev — they are exceptionally well written and example-driven.
- Run with
--debugor open UI Mode (you'll learn both in Module 9) — seeing the browser state at the moment of failure resolves 90% of confusion instantly. - Search the exact error text in GitHub Issues — Playwright's maintainers are unusually responsive and most odd behaviors are already documented.
Open a terminal right now and run node -v. If you see a version number, you're ready for Module 2. If you get "command not found," Module 2 starts with installing Node — no problem either way.
What Is Playwright, and Why It Matters
Playwright is an open-source framework, created and maintained by Microsoft, for automating real web browsers — meaning it can click buttons, fill forms, navigate pages, and verify what's on screen exactly the way a human user would, except programmatically and at machine speed.
It's used for two overlapping purposes: end-to-end (E2E) testing (does the checkout flow actually work after this code change?) and general browser automation (scraping, generating PDFs, monitoring sites, filling repetitive forms). This course focuses on testing, since that's where Playwright is most widely adopted, but every technique transfers directly to automation use cases.
The core idea: it drives real browser engines
Playwright doesn't simulate a browser — it controls actual browser engines: Chromium (which powers Chrome and Edge), WebKit (which powers Safari), and Firefox. One test script, three real engines, with no code changes required between them. This is what "cross-browser testing" means in practice.
What makes Playwright distinctive
- Auto-waiting. Before clicking or typing, Playwright automatically waits for an element to be attached to the DOM, visible, stable (not animating), and able to receive events. This single feature eliminates the majority of "flaky test" causes that plague older tools. You'll go deep on this in Module 3.
- Browser contexts. Each test can spin up an isolated, cookie-free "incognito-like" browser profile in milliseconds — meaning tests don't leak state into each other and can run safely in parallel.
- One API across browsers and languages. The same Playwright API is available in JavaScript/TypeScript, Python, Java, and .NET. This course uses JavaScript/TypeScript, the most widely used and best-documented option.
- Built-in test runner. Unlike Selenium (which is "just" a browser driver), Playwright ships
@playwright/test— a full test runner with parallelization, retries, fixtures, reporters, and a trace viewer included. - Network control. You can intercept, modify, block, or mock any network request a page makes, without a proxy server. Module 8 covers this in depth.
- First-class debugging tools. Codegen (record actions into code), Trace Viewer (a full timeline replay of a test run with DOM snapshots, console logs, and network activity), and UI Mode (an interactive, watch-mode test runner). Module 9 is dedicated to these.
- Mobile emulation. Playwright can emulate device viewports, touch input, geolocation, and user agents for common phones and tablets without needing real devices for most testing purposes.
Think of Playwright as having three layers: (1) the browser drivers that talk directly to Chromium/WebKit/Firefox internals — fast and reliable because there's no middleman protocol; (2) the Playwright library — the API you call (page.click(), page.fill(), etc.); (3) @playwright/test — the test runner that organizes, parallelizes, and reports on tests built with that library. You'll use all three together, but understanding they're separate layers helps when reading documentation.
A first taste of what the code looks like
example only — don't run this yet, Module 2 sets up the project properlyimport { test, expect } from '@playwright/test';
test('homepage has the right title', async ({ page }) => {
await page.goto('https://playwright.dev');
await expect(page).toHaveTitle(/Playwright/);
});
Read that out loud: "open playwright.dev, then expect the page to have a title containing the word Playwright." Playwright's API is deliberately written to read close to plain English — that readability is itself a design goal, not an accident.
Visit playwright.dev right now and look at the homepage. Can you spot three things on that page a test might want to verify exist (a heading, a button, a search box)? Keep that page open — you'll be writing real tests against demo sites very soon.
Playwright vs Selenium vs Cypress
You don't strictly need this comparison to learn Playwright, but understanding why it's designed the way it is makes the upcoming concepts click faster — and you'll likely be asked about this trade-off in interviews or team discussions.
| Selenium WebDriver | Cypress | Playwright | |
|---|---|---|---|
| Architecture | Talks to browsers via the W3C WebDriver protocol — an extra translation layer between your code and the browser. | Runs inside the browser itself, alongside your application code. | Talks directly to browser engines via DevTools Protocol (Chromium) / equivalent low-level protocols — no WebDriver middleman. |
| Cross-browser | Broadest legacy support (incl. old IE) via separate driver binaries per browser. | Historically Chromium-family only; later added Firefox and WebKit (Safari engine) support, though community consensus still treats it as Chromium-first. | Native first-class support for Chromium, Firefox, and WebKit from one API. |
| Auto-waiting | Largely manual — you write explicit waits or risk flakiness. | Built-in retry-ability for commands and assertions. | Built-in for actions and assertions, with configurable timeouts. |
| Multi-tab / multi-origin | Supported, with manual window-handle switching. | Historically restrictive — cross-origin and multi-tab scenarios needed workarounds. | Native support for multiple tabs, windows, and iframes, including cross-origin, in one test. |
| Parallelization | Possible via Selenium Grid, but requires separate infrastructure setup. | Built into Cypress Cloud (paid) for cross-machine; local parallel is more limited. | Built into the test runner via workers — free, local, and CI-friendly out of the box. |
| Debugging tools | Mostly third-party / IDE-dependent. | Strong: time-travel snapshots in the Cypress app. | Strong: Trace Viewer (full timeline + DOM + network + console), UI Mode, and Codegen, all free and built-in. |
| Languages | Java, Python, C#, JavaScript, Ruby, and more. | JavaScript/TypeScript only. | JavaScript/TypeScript, Python, Java, .NET. |
| API testing | Not built in. | Possible but secondary to UI testing. | First-class request context for pure API testing, usable alongside or independent of UI tests. |
This isn't "Playwright is strictly better." Selenium's maturity and language breadth still matter for some enterprises with existing Java/C# infrastructure or niche browser/device requirements. Cypress's developer experience and component-testing story has loyal fans, especially for teams already deep in its ecosystem. Playwright has simply become the most common default recommendation for new projects since roughly 2022–2023 because it covers the broadest set of real-world testing needs with the least workaround code — and that trend is reflected in job postings and OSS adoption, though you should still evaluate against your own team's stack.
Why this course teaches Playwright specifically
- One mental model (auto-waiting + locators) covers UI, API, mobile-emulation, and visual testing.
- The free built-in debugging tools mean you rarely need paid add-ons to diagnose failures.
- It's the framework most commonly paired with modern AI-assisted and agentic testing pipelines (MCP servers, codegen-assisted authoring), which is increasingly relevant if you're building automation tooling rather than just tests.
Module 1 Quiz
Installing Node.js & Playwright
Playwright (the JS/TS version) runs on Node.js, so that's step one if you don't already have it.
Step 1 — Install Node.js
Download the LTS (Long-Term Support) version from nodejs.org and run the installer for your OS. LTS, not "Current" — LTS is the stable line teams use in production.
terminal — verify the installnode -v
npm -v
You should see version numbers for both (Node 18 or newer is required for current Playwright). If you see "command not found," restart your terminal — installers sometimes need a fresh shell to register the PATH.
Step 2 — Create a project and install Playwright
Make a new folder for the course exercises, then run Playwright's official scaffolding command from inside it:
terminalmkdir pw-course && cd pw-course
npm init playwright@latest
This launches an interactive setup wizard. Here's what each prompt means and what to choose:
| Prompt | Recommended answer |
|---|---|
| TypeScript or JavaScript? | TypeScript — even if you've never used it, Playwright's TypeScript is gentle and the autocomplete/type-checking catches mistakes before you run anything. This course's examples work in both. |
| Tests folder name? | Accept the default, tests. |
| Add a GitHub Actions workflow? | Yes — it scaffolds a working CI pipeline file you'll actually use in Module 10. |
| Install Playwright browsers now? | Yes — this downloads Chromium, Firefox, and WebKit binaries (a few hundred MB total). Required before your first test run. |
Corporate networks/VPNs sometimes block the binary download. You can re-run it any time with npx playwright install, and install just one browser with npx playwright install chromium if you want to start faster and add the others later.
What just got created
Open the pw-course folder in VS Code (code . from the terminal, if you've installed the code CLI shortcut). You'll see a handful of new files and folders — the next lesson walks through every one of them and what they do.
Search "Playwright Test for VSCode" in the Extensions panel (publisher: Microsoft) and install it. It adds a sidebar to run/debug individual tests by clicking a play button next to them, without touching the terminal — you'll use this constantly from Module 9 onward.
Run npx playwright test right now, before reading further. Three example tests should run and pass (you'll see a summary like "3 passed"). If that worked, your environment is fully set up — everything from here is concepts and code, not more installing.
Project Tour & the Config File
Here's what npm init playwright@latest generated, and what each piece is for:
pw-course/
├── tests/
│ └── example.spec.ts # sample tests — safe to delete once you've read them
├── tests-examples/
│ └── demo-todo-app.spec.ts # a longer worked example, good reference later
├── playwright.config.ts # the single most important config file in the project
├── package.json
├── .github/workflows/
│ └── playwright.yml # ready-to-use CI pipeline (Module 10)
└── node_modules/
The config file, annotated
You'll come back to playwright.config.ts constantly. Here's a trimmed, explained version of the defaults:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests', // where Playwright looks for *.spec.ts files
fullyParallel: true, // run tests within a file in parallel too, not just across files
retries: process.env.CI ? 2 : 0, // auto-retry failed tests on CI (flakiness safety net)
reporter: 'html', // generates a browsable HTML report after each run
use: {
baseURL: 'https://example.com', // lets you write page.goto('/login') instead of full URLs
trace: 'on-first-retry', // records a full debugging trace only when a test fails+retries
},
projects: [ // defines which browsers/devices to run against
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});
| Option | Why it matters |
|---|---|
testDir | Change this if you organize tests outside the default tests/ folder. |
retries | A pragmatic flakiness buffer for CI — locally you usually want 0 so failures are obvious immediately. |
use.baseURL | Set this once to your app's URL and every page.goto() in every test can use a relative path — huge for switching between staging/prod environments via env vars. |
use.trace | The trace viewer (Module 9) is Playwright's best debugging tool — leave this on at least for retries. |
projects | Each entry is a full "run profile." Adding a project for mobile viewports, different locales, or authenticated vs. unauthenticated states is extremely common (more in Module 8). |
npx playwright test --project=chromium runs only against Chromium — much faster during day-to-day development. Save the full three-browser run for CI or pre-merge checks.
Your First Test, Line by Line
Delete the contents of tests/example.spec.ts (or make a new file, tests/first.spec.ts) and write this from scratch — typing it, not pasting it:
import { test, expect } from '@playwright/test';
test('can search the Playwright docs', async ({ page }) => {
// 1. Navigate
await page.goto('https://playwright.dev/');
// 2. Find and click the search box
await page.getByRole('button', { name: 'Search' }).click();
// 3. Type a query
await page.getByPlaceholder('Search docs').fill('locators');
// 4. Assert a result appears
await expect(page.getByRole('link', { name: /Locators/ }).first()).toBeVisible();
});
Breaking down every line
import { test, expect }— these two functions are essentially the entire Playwright Test API surface you call directly.testdefines a test case;expectmakes assertions.test('description', async ({ page }) => {...})— the string is a human-readable name shown in reports.{ page }is a fixture — Playwright automatically creates a fresh browser page for this test and hands it to you. You'll learn fixtures properly in Module 7, but for now:pageis "the browser tab you're controlling."await page.goto(...)— every Playwright action that touches the browser is asynchronous, so it'sawaited. Forgettingawaitis the single most common beginner bug — Module 4's mistake box covers it.page.getByRole(...)— a locator: a recipe for finding an element, evaluated lazily. Module 4 is entirely about these..click()— an action. Playwright automatically waits for the element to be ready before clicking — no manual sleep/wait code needed.expect(...).toBeVisible()— a web-first assertion. Unlike a plainifcheck, this automatically retries for a few seconds until it's true or times out, which is why Playwright tests are far less flaky than older tools.
Running it
# run everything headless (no visible browser window) — fastest, what CI uses
npx playwright test
# run just this file
npx playwright test first.spec.ts
# run with a real visible browser window — great for watching what happens
npx playwright test --headed
# open the interactive HTML report after a run
npx playwright show-report
Beginners often write page.click('button') style code with raw CSS strings copy-pasted from browser DevTools (e.g. #root > div > div.nav > button:nth-child(3)). It works today and breaks the moment a designer reorders a div. Module 4 explains exactly why getByRole/getByText style locators survive UI changes that CSS-position selectors don't.
Run the test with --headed and actually watch the browser do the search. Then deliberately break it — change 'Search docs' to 'Search docssss' — run it again, and read the resulting error message top to bottom. Notice how precisely it tells you what it was looking for and what it found instead.
Module 2 Quiz
Browser → Context → Page
Three objects sit underneath almost everything you'll write. Understanding the hierarchy explains a lot of Playwright's behavior that would otherwise feel arbitrary.
| Object | Real-world equivalent | Cost to create |
|---|---|---|
| Browser | The actual browser application (Chrome, Firefox, Safari) running as a process. | Expensive — launched once and reused across many tests. |
| BrowserContext | An isolated "incognito window" — its own cookies, local storage, and cache. | Cheap — created fresh per test in milliseconds, fully isolated. |
| Page | A single tab inside that incognito window. | Cheap — a context can have multiple pages (tabs) open at once. |
@playwright/test manages the Browser and BrowserContext for you automatically — that's what the { page } fixture you saw in Module 2 is built on top of. Every test gets a brand-new, isolated context by default, which is precisely why tests don't see each other's cookies or logins unless you explicitly share state (Module 8 covers exactly that, for login reuse).
Seeing it explicitly (you won't usually write this, but it's worth seeing once)
import { chromium } from '@playwright/test';
const browser = await chromium.launch(); // 1 — start the browser process
const context = await browser.newContext(); // 2 — isolated "incognito" session
const page = await context.newPage(); // 3 — a tab inside it
await page.goto('https://playwright.dev');
await browser.close();
This is exactly what @playwright/test does behind the scenes for every test — it just hides the ceremony so your test files only contain the part that matters: what you actually want to test.
Because each test gets its own context, you can safely run 50 tests in parallel across multiple workers without one test's login session, cookies, or local storage leaking into another's. This is a major reason Playwright suites run fast — parallelism doesn't require you to write any extra isolation code.
Multiple pages in one context
Real sites open new tabs (e.g. "View invoice" opening a PDF in a new tab). Because a context can hold several pages, this is natural to handle:
const [newPage] = await Promise.all([
context.waitForEvent('page'), // wait for the new tab to open
page.getByText('Open in new tab').click(), // the action that triggers it
]);
await newPage.waitForLoadState();
await expect(newPage).toHaveURL(/invoice/);
You'll use this exact pattern again in Module 5 when handling popups and new tabs.
If you ever see test data "bleeding" between tests (a login persisting when it shouldn't, or a previous test's data showing up), the cause is almost always that someone manually reused a context or page across tests instead of letting @playwright/test create a fresh one per test. Default behavior is isolated — fight to keep it that way.
Auto-Waiting: Playwright's Superpower
If you take exactly one mental model from this entire course, make it this one. It's the reason Playwright tests are dramatically less flaky than Selenium tests written without careful manual waits.
The problem auto-waiting solves
Modern web pages are not instantaneous — a button might exist in the DOM before it's clickable (still disabled, still animating in, covered by a loading spinner, or not yet attached to its click handler). Older tools would try to click immediately and fail with "element not interactable," forcing developers to litter code with sleep(2000) calls that are both slow and still unreliable.
What Playwright actually checks before acting
Before performing an action like .click() or .fill(), Playwright runs through a series of actionability checks and retries them for up to the configured timeout (default 30 seconds, but checks usually pass in milliseconds):
- Attached — the element exists in the DOM.
- Visible — it has non-zero size and isn't hidden via CSS.
- Stable — it isn't mid-animation (its bounding box hasn't moved in two consecutive frames).
- Receives events — it isn't covered by another element (like a modal overlay or loading spinner).
- Enabled — for form controls, it isn't disabled.
If all checks pass, the action happens. If any fail, Playwright re-checks them continuously until they pass or the timeout is hit — at which point it throws a detailed error telling you exactly which check failed.
What this looks like in practice
// You write this single line...
await page.getByRole('button', { name: 'Submit' }).click();
// ...and Playwright is effectively doing this loop internally, many times per second:
// - is #submit-btn attached? yes
// - is it visible? no (still behind a loading spinner) -> retry
// - is it visible? yes
// - is it stable? yes
// - does it receive events? yes
// - is it enabled? yes
// -> click.
Web-first assertions like expect(locator).toBeVisible() use the same retry mechanism — they don't check once and fail, they poll until true or timeout. This is why you should always prefer await expect(locator).toHaveText('Saved') over manually reading text and comparing it with a plain if. Module 6 covers this fully.
What auto-waiting does not do
It is not magic for everything. It won't wait for:
- A network request that hasn't been triggered yet (you may need
page.waitForResponse()— Module 8). - Business logic delays disguised as "the element exists but the data inside it is stale" (you need to assert on the actual content, not just presence).
- Client-side routing transitions in some SPA frameworks where the URL changes before the new page's content is ready — assert on content, not just
toHaveURL(), when in doubt.
Adding manual waits "just in case": await page.waitForTimeout(3000); await button.click();. This is almost always unnecessary with auto-waiting and actively makes your suite slower and, ironically, sometimes more flaky (it masks the real timing issue instead of letting Playwright's retry logic handle it correctly). Reach for waitForTimeout only as an absolute last resort for debugging — never ship it.
Write a test that clicks a button on any page, then immediately add console.log(await button.boundingBox()) right before the click. Run it twice — once normally, once after artificially slowing your network in DevTools (Network tab → Slow 3G). Notice the test still passes either way without you changing a single line.
Anatomy of a Test File
You've seen single test() blocks. Real suites organize many tests using a small, consistent vocabulary — all of it imported from @playwright/test.
import { test, expect } from '@playwright/test';
test.describe('Login page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login'); // runs before every test in this describe block
});
test('shows an error for invalid credentials', async ({ page }) => {
await page.getByLabel('Email').fill('wrong@example.com');
await page.getByLabel('Password').fill('wrongpass');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByText('Invalid credentials')).toBeVisible();
});
test('redirects to dashboard on success', async ({ page }) => {
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('correct-password');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL('/dashboard');
});
test.afterEach(async ({ page }, testInfo) => {
if (testInfo.status !== testInfo.expectedStatus) {
console.log(`Test failed: ${testInfo.title}`);
}
});
});
| Block | Purpose |
|---|---|
test.describe() | Groups related tests for organization and shared setup. Purely organizational — doesn't change execution unless combined with hooks. |
test.beforeEach() | Runs before every test in its scope. Ideal for navigation and setup shared by all tests in the group. |
test.afterEach() | Runs after every test, even if it failed — useful for cleanup or conditional logging, as shown above. |
test.beforeAll() / test.afterAll() | Run once per worker process for the whole file/describe block — for genuinely expensive one-time setup. Used sparingly, since state here is shared across tests and can reintroduce the isolation problems Module 3.1 described. |
Default to beforeEach. It re-runs setup per test, which costs a little time but guarantees isolation. Reach for beforeAll only when setup is genuinely expensive and safe to share (e.g., seeding a read-only reference dataset) — using it for things like login state, when tests then mutate that state, is a common source of order-dependent test failures.
Naming and describing well
Test names show up directly in CI output and HTML reports, so write them as sentences describing behavior, not implementation: 'shows an error for invalid credentials', not 'test login fail case'. Future-you (or a teammate) should be able to understand what broke from the test name alone, without opening the file.
Take the single test you wrote in Module 2.3 and wrap it in a test.describe('Playwright docs site', () => {...}) block, then add a second test inside the same describe block that checks the page has a "Get started" link visible. Run both with npx playwright test and confirm the report shows them grouped together.
Module 3 Quiz
getBy* — The Recommended Way to Find Elements
Locators are arguably the single most important concept in this whole course — almost every bug in a beginner's test suite traces back to a poorly chosen locator. Get this module right and everything else gets dramatically easier.
A locator is a recipe, not a result
This is the part that confuses people coming from Selenium. page.getByRole('button', { name: 'Submit' }) does not search the page when you write it — it creates a lazy, reusable description of how to find that element, which is only evaluated the moment you call an action (.click()) or assertion (expect(...).toBeVisible()) on it. This is exactly what enables auto-waiting and retries from Module 3 — Playwright re-runs the "recipe" every time it needs to, picking up elements that appear later.
// This line does NOT search the DOM yet — it's just a description
const submitBtn = page.getByRole('button', { name: 'Submit' });
// THIS is when Playwright actually looks for it (and waits/retries if needed)
await submitBtn.click();
The getBy* family — in priority order
Playwright's official guidance, and this course's strong recommendation, is to prefer locators based on what a user would see or what assistive technology would announce, rather than implementation details like CSS classes or DOM structure. Use this priority order:
| Locator | Finds elements by... | Example |
|---|---|---|
getByRole | ARIA role + accessible name — closest to how a screen reader "sees" the page. First choice for almost everything. | page.getByRole('button', { name: 'Sign in' }) |
getByLabel | The <label> text associated with a form field. | page.getByLabel('Email address') |
getByPlaceholder | An input's placeholder attribute. | page.getByPlaceholder('Search...') |
getByText | Visible text content anywhere on the page. | page.getByText('Welcome back') |
getByAltText | An image's alt attribute. | page.getByAltText('Company logo') |
getByTitle | An element's title attribute (tooltip text). | page.getByTitle('Close dialog') |
getByTestId | A dedicated data-testid attribute — the deliberate escape hatch for elements with no good accessible identity. | page.getByTestId('cart-total') |
Common ARIA roles include button, link, checkbox, textbox, heading, list, and listitem. A <button> element automatically has role button with no extra markup needed — Playwright reads this directly from the browser's accessibility tree, the same data screen readers use. That means tests built this way double as a lightweight accessibility check: if getByRole('button', {name:'Submit'}) can't find your submit button, a screen reader user probably can't either.
The accessible name matters, and it's flexible
// Exact string match
page.getByRole('button', { name: 'Submit' });
// Regex — handy when text has dynamic parts or you want partial/case-insensitive match
page.getByRole('button', { name: /submit/i });
// Exact match, including whitespace/case (default is substring-tolerant for strings... be precise when needed)
page.getByRole('button', { name: 'Submit', exact: true });
Common roles you'll use constantly
page.getByRole('heading', { name: 'Dashboard' });
page.getByRole('link', { name: 'Pricing' });
page.getByRole('textbox', { name: 'Username' });
page.getByRole('checkbox', { name: 'Remember me' });
page.getByRole('listitem'); // no name filter — matches all list items
page.getByRole('navigation'); // the <nav> landmark
page.getByRole('alert'); // error/toast messages with role="alert"
Run npx playwright codegen https://your-site.com (you'll learn this properly in Module 9) and hover elements — it shows you the exact locator Playwright would generate, including the correct role and accessible name, without you needing to memorize the full ARIA spec.
Go back to your Module 2 test file. Replace any locator you wrote with the most specific getByRole version you can, and re-run the test. If you used getByPlaceholder already, try rewriting it as getByRole('textbox', {name: ...}) instead and confirm it still works — textboxes with labels usually support both.
CSS, XPath, Chaining & Filtering
Real pages aren't always accessible-markup-perfect. Sometimes you genuinely need CSS selectors, XPath, or ways to narrow down a locator that matches multiple elements. Playwright supports all of this without making you abandon the getBy* approach entirely.
CSS and XPath, when you need them
page.locator('.cart-icon'); // CSS class
page.locator('#submit-button'); // CSS id
page.locator('[data-state="open"]'); // CSS attribute selector
page.locator('xpath=//div[@class="price"]'); // XPath, when CSS truly can't express it
Use these as a fallback, not a default — they couple your tests to implementation details (a class rename breaks the test even though nothing user-facing changed).
Chaining locators
You can narrow a search by chaining .locator() calls — each step searches only within the previous result:
// Find the "Add to cart" button, but only inside the product card titled "Blue Mug"
page.locator('.product-card', { hasText: 'Blue Mug' })
.getByRole('button', { name: 'Add to cart' });
Filtering — narrowing down ambiguous matches
When a locator could match multiple elements, .filter() narrows it without writing a more specific selector from scratch:
// All rows, filtered down to the one containing "Invoice #4471"
page.getByRole('row').filter({ hasText: 'Invoice #4471' });
// Filter using a nested locator instead of text
page.getByRole('listitem').filter({ has: page.getByRole('button', { name: 'Delete' }) });
// Filter OUT elements
page.getByRole('listitem').filter({ hasNot: page.getByText('Out of stock') });
Picking one of several matches by position
page.getByRole('listitem').first();
page.getByRole('listitem').last();
page.getByRole('listitem').nth(2); // zero-indexed — third item
.nth() is a last resort. Lists reorder, items get added/removed, and "the 3rd item" silently becomes the wrong item with zero error — your test still runs, it just tests the wrong thing. Prefer .filter({ hasText: ... }) so the test describes what it's targeting, not where it happens to sit.
Combining locators (AND / OR logic)
// AND — must match both
page.locator('button').and(page.getByRole('button', { name: 'Submit' }));
// OR — matches either, useful for "this button OR this other element appears"
page.getByText('Confirm').or(page.getByText('Proceed'));
Handling "strict mode" errors
By default, calling an action on a locator that matches more than one element throws an error rather than silently clicking the first one — this is called strict mode, and it's deliberate. It surfaces ambiguous locators as test failures immediately, instead of letting them silently click the wrong element and pass for the wrong reason.
// Throws: "strict mode violation: locator resolved to 3 elements"
await page.getByRole('button').click();
// Fix it by being more specific, not by disabling strict mode
await page.getByRole('button', { name: 'Submit' }).click();
On any page with a list (try demo.playwright.dev/todomvc — add a few todo items first), write a locator using .filter({ hasText: ... }) to find one specific item, then chain .getByRole('button') off it to find that item's delete button specifically.
Locator Best Practices & Common Mistakes
A checklist worth re-reading after you've written your first real suite, when the patterns actually mean something to you.
Do
- Prefer
getByRole>getByLabel/getByPlaceholder>getByText>getByTestId> CSS/XPath, in that order, falling back only when the option above genuinely doesn't work. - Add
data-testidattributes to your application's source code for elements with no meaningful text or role (icon-only buttons, decorative containers) — this is a legitimate, common practice, not a failure of the "use accessible locators" rule. - Re-declare a locator inside each test/step rather than sharing one mutable variable across unrelated tests — locators are cheap to create and this avoids confusing stale-reference bugs.
- Let strict-mode errors guide you to a more specific locator instead of reaching for
.first()reflexively.
Don't
- Don't use auto-generated CSS selectors copied straight from browser DevTools (
"Copy selector") — they're tied to DOM structure and break on the next refactor. - Don't chain excessively deep CSS paths (
div > div > ul > li:nth-child(2) > span) — if you can't describe the element by its role or text, that's often a sign the underlying HTML needs better semantics (which is itself useful feedback for accessibility). - Don't forget
awaiton actions — a missingawaitdoesn't always throw immediately; it can cause actions to run out of order or finish after the test has already asserted, producing confusing intermittent failures.
// WRONG — click() returns a Promise; without await, the test moves on
// before the click has actually happened.
test('broken example', async ({ page }) => {
page.getByRole('button', { name: 'Submit' }).click(); // missing await!
await expect(page.getByText('Success')).toBeVisible(); // may run too early
});
// RIGHT
test('fixed example', async ({ page }) => {
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText('Success')).toBeVisible();
});
A good habit: enable ESLint with the official eslint-plugin-playwright package — its no-floating-promises-style rule flags missing await calls automatically, before you even run the test.
Quick decision tree for "which locator do I use?"
- Is it a button, link, heading, checkbox, or other standard interactive/semantic element? →
getByRole. - Is it a form field with a visible label? →
getByLabel. - Does it have unique, stable visible text and no role? →
getByText. - Is it an icon-only control or genuinely has nothing identifying about it? → ask for (or add) a
data-testid, then usegetByTestId. - Only as a last resort — CSS or XPath, and leave a comment explaining why.
Audit any test you've written so far for a single thing: every action line starts with await. This one habit will save you more debugging time over your career than almost anything else in this course.
Module 4 Quiz
Core Actions
Once you can locate an element (Module 4), interacting with it is the easy part — Playwright's action API maps closely to what a user physically does.
Clicking
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Settings').dblclick();
await page.getByRole('button', { name: 'Open menu' }).click({ button: 'right' }); // right-click
await page.getByRole('checkbox').click({ modifiers: ['Shift'] }); // shift-click
Typing into fields
// fill() — sets the value directly (fast, recommended for most cases)
await page.getByLabel('Email').fill('user@example.com');
// pressSequentially() — types character by character (use when a field has
// JS that reacts to each keystroke, e.g. live validation or autocomplete)
await page.getByLabel('Search').pressSequentially('playwright', { delay: 50 });
// clear() — empties a field
await page.getByLabel('Email').clear();
fill() sets the input's value in one operation and fires the appropriate change events — it's faster and is the right default. Reach for pressSequentially() only when you specifically need to test keystroke-by-keystroke behavior, like a live character counter or autocomplete dropdown that reacts per key.
Checkboxes, radio buttons & dropdowns
await page.getByLabel('I agree to the terms').check();
await page.getByLabel('I agree to the terms').uncheck();
await page.getByRole('radio', { name: 'Standard shipping' }).check();
// Native <select> dropdown
await page.getByLabel('Country').selectOption('Ireland'); // by visible label
await page.getByLabel('Country').selectOption({ value: 'IE' }); // by option value
await page.getByLabel('Tags').selectOption(['JS', 'TS']); // multi-select
Keyboard & focus
await page.getByLabel('Search').press('Enter');
await page.keyboard.press('Control+A'); // page-level, not tied to one locator
await page.getByLabel('Comment').focus();
Reading values back out
const value = await page.getByLabel('Email').inputValue();
const text = await page.getByRole('heading').textContent();
const isChecked = await page.getByLabel('Subscribe').isChecked();
const count = await page.getByRole('listitem').count();
These return real JavaScript values you can use in test logic — though for verification specifically, prefer expect() assertions (Module 6) over manually reading and comparing, since assertions get auto-retry and better failure messages for free.
On demo.playwright.dev/todomvc, write a test that: types a todo into the input and presses Enter, checks the checkbox next to it to mark it complete, and reads back .count() of remaining (unchecked) items.
Advanced Actions: Hover, Drag, Upload, Tabs, iFrames & Dialogs
Hover & drag-and-drop
await page.getByText('Account').hover(); // reveals a dropdown menu, e.g.
await page.getByRole('menuitem', { name: 'Logout' }).click();
// Drag and drop between two elements
await page.getByText('Task A').dragTo(page.getByText('In Progress column'));
// Manual drag for finer control (e.g. sliders, canvases)
await page.getByRole('slider').hover();
await page.mouse.down();
await page.mouse.move(300, 0);
await page.mouse.up();
File uploads
// Standard <input type="file">
await page.getByLabel('Upload résumé').setInputFiles('tests/fixtures/resume.pdf');
// Multiple files
await page.getByLabel('Upload photos').setInputFiles(['photo1.jpg', 'photo2.jpg']);
// Custom upload UI (button that triggers a hidden file picker)
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.getByText('Choose files').click(),
]);
await fileChooser.setFiles('tests/fixtures/resume.pdf');
File downloads
const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByText('Download report').click(),
]);
console.log(await download.suggestedFilename());
await download.saveAs('downloads/report.csv');
New tabs & popup windows
const [popup] = await Promise.all([
page.waitForEvent('popup'),
page.getByText('Open in new tab').click(),
]);
await popup.waitForLoadState();
await expect(popup.getByRole('heading')).toBeVisible();
iFrames
Elements inside an <iframe> aren't found by the normal page.getBy* calls — you scope into the frame first:
const frame = page.frameLocator('iframe[title="Payment form"]');
await frame.getByLabel('Card number').fill('4242 4242 4242 4242');
await frame.getByRole('button', { name: 'Pay' }).click();
frameLocator() returns something that supports the exact same getByRole/getByText/etc. API as page does — so nothing you learned in Module 4 is wasted, you're just scoping the search into the iframe's own document.
Browser dialogs (alert, confirm, prompt)
Native browser dialogs block normal page interaction, so you must register a handler before the action that triggers them:
page.on('dialog', async (dialog) => {
console.log(dialog.message()); // e.g. "Are you sure you want to delete this?"
await dialog.accept(); // or dialog.dismiss()
});
await page.getByRole('button', { name: 'Delete account' }).click();
Registering the dialog handler after clicking the button that triggers it. By then the dialog has already appeared and is blocking the page — Playwright auto-dismisses unhandled dialogs to avoid hanging forever, which silently changes your test's behavior. Always register the handler first, then perform the triggering action.
Find any site with a confirm() dialog (many "delete" buttons trigger one) and write a test that handles it with both .accept() and, in a second test, .dismiss() — verify the resulting page state differs correctly between the two.
Module 5 Quiz
expect() & Web-First Assertions
Assertions are how a test states what it expects to be true and fails loudly when it isn't. Playwright's expect (re-exported from its own assertion library, similar in spirit to Jest's) has a category called web-first assertions that you should reach for almost exclusively when asserting on the page.
Why "web-first" assertions are different
A normal assertion checks a value once. A web-first assertion, when given a locator, automatically retries — re-checking the condition repeatedly until it's true or the timeout elapses. This means you can assert on something that hasn't appeared yet without writing any manual wait code:
// This does NOT fail immediately if the toast hasn't appeared yet —
// it polls for up to the timeout, succeeding as soon as it's true.
await expect(page.getByText('Saved successfully')).toBeVisible();
The assertions you'll use constantly
await expect(locator).toBeVisible();
await expect(locator).toBeHidden();
await expect(locator).toBeEnabled();
await expect(locator).toBeDisabled();
await expect(locator).toBeChecked();
await expect(locator).toBeFocused();
await expect(locator).toHaveText('Exact text');
await expect(locator).toContainText('partial');
await expect(locator).toHaveValue('user@example.com'); // for inputs
await expect(locator).toHaveAttribute('href', '/dashboard');
await expect(locator).toHaveClass(/active/);
await expect(locator).toHaveCount(3); // for collections
await expect(page).toHaveTitle(/Dashboard/);
await expect(page).toHaveURL(/\/dashboard$/);
Negating any assertion
await expect(page.getByText('Error')).not.toBeVisible();
await expect(locator).not.toHaveClass(/disabled/);
Page-level vs element-level vs plain assertions
| Type | Example | Retries automatically? |
|---|---|---|
| Element (locator) | expect(locator).toBeVisible() | Yes |
| Page | expect(page).toHaveURL(...) | Yes |
| Plain value | expect(sum).toBe(4) | No — checked once, like standard Jest/Chai assertions |
Use plain assertions for non-UI values (numbers, computed results, API response bodies you've already parsed). Use web-first (locator/page) assertions for anything on the live page — that distinction is the whole reason both styles exist side by side.
// WRONG — reads text once, with no retry; fails if the text hasn't
// rendered yet even though it will appear a moment later.
const text = await page.getByText('Saved').textContent();
expect(text).toBe('Saved');
// RIGHT — retries automatically until it matches or times out.
await expect(page.getByText('Saved')).toHaveText('Saved');
Take a test you've already written and replace any const x = await locator.textContent(); expect(x).toBe(...) pattern with the equivalent await expect(locator).toHaveText(...) form. Notice the test code gets shorter, not just more correct.
Soft Assertions, Polling & Custom Matchers
Soft assertions — collect multiple failures in one run
By default, the first failed assertion stops the test immediately. Sometimes you want to check several things and see all the failures at once — useful for visual/layout checks across many elements:
await expect.soft(page.getByRole('heading')).toHaveText('Dashboard');
await expect.soft(page.getByTestId('user-count')).toHaveText('42 users');
await expect.soft(page.getByRole('button', { name: 'Export' })).toBeVisible();
// test continues even if one of the above fails, and reports ALL failures together
// at the end of the test
For most tests, a hard expect() that stops immediately is the right behavior — it keeps tests focused and prevents a cascade of confusing secondary failures caused by the first real problem. Reach for .soft() specifically when you want a "checklist-style" test, not as a default habit.
expect.poll — for asserting on arbitrary, non-locator values
Sometimes the thing you need to wait for isn't a locator at all — maybe a value from an API call, a database query, or a computed function. expect.poll brings the same retry behavior to any function:
await expect.poll(async () => {
const response = await fetch('https://api.example.com/job-status/42');
const data = await response.json();
return data.status;
}, { timeout: 10_000 }).toBe('completed');
Custom timeout per assertion
// Override the default timeout for just this one assertion
await expect(page.getByText('Processing complete')).toBeVisible({ timeout: 15_000 });
Writing a custom matcher (advanced, optional)
For teams writing many similar assertions, Playwright lets you extend expect with your own matchers — most learners won't need this immediately, but it's worth knowing exists:
import { expect as baseExpect } from '@playwright/test';
export const expect = baseExpect.extend({
async toBeValidEmail(locator) {
const value = await locator.inputValue();
const pass = /^[^@]+@[^@]+\.[^@]+$/.test(value);
return {
pass,
message: () => `expected ${value} to be a valid email`,
};
},
});
// usage in a test: await expect(emailInput).toBeValidEmail();
Write a test with three expect.soft() assertions where you deliberately make one of them wrong (e.g. expect text that doesn't exist). Run it and read the report — notice it tells you about all three checks, not just the first failure, and still marks the overall test as failed.
Module 6 Quiz
Hooks, test.step & Tags
You already met beforeEach/afterEach in Module 3. This lesson rounds out the full toolkit Playwright gives you for organizing test files as they grow past a handful of tests.
The full hook hierarchy
| Hook | Runs | Typical use |
|---|---|---|
beforeAll | Once, before all tests in the file/describe block | Expensive one-time setup (seed a database, start a server) |
beforeEach | Before every single test | Navigate to a starting page, log in, reset state |
afterEach | After every single test | Take a screenshot on failure, clean up created data |
afterAll | Once, after all tests in the file/describe block | Tear down shared resources from beforeAll |
Unlike beforeEach (which gets a fresh page per test), code in beforeAll doesn't automatically get a page argument tied to an isolated context — you'd need to create one manually via browser.newPage() if you need page interactions there. In practice, most learners should reach for beforeEach by default and only use beforeAll for non-browser setup (database seeding, API calls to prepare data).
test.step — labelled, collapsible steps inside one test
As tests grow longer, test.step groups related actions under a readable label. This pays off enormously in the HTML report and trace viewer — long tests become a navigable outline instead of a wall of actions:
test('user can complete checkout', async ({ page }) => {
await test.step('add item to cart', async () => {
await page.goto('/products/widget');
await page.getByRole('button', { name: 'Add to cart' }).click();
});
await test.step('go to checkout and fill shipping details', async () => {
await page.getByRole('link', { name: 'Checkout' }).click();
await page.getByLabel('Full name').fill('Ada Lovelace');
await page.getByLabel('Address').fill('1 Analytical Engine Way');
});
await test.step('submit payment and verify confirmation', async () => {
await page.getByRole('button', { name: 'Place order' }).click();
await expect(page.getByText('Order confirmed')).toBeVisible();
});
});
Steps can also nest, and each one shows its own duration in the report — invaluable when a 40-second test fails and you need to know which 2-second chunk of it actually broke.
Tags and annotations
Tags let you run a subset of your suite — for example, only smoke tests before a deploy, or skip slow tests during local development:
test('checkout flow works end to end @smoke @checkout', async ({ page }) => {
// ...
});
test('handles 50 items in cart without performance degradation @slow', async ({ page }) => {
// ...
});
# Run only tests tagged @smoke
npx playwright test --grep @smoke
# Run everything EXCEPT tests tagged @slow
npx playwright test --grep-invert @slow
You can also mark tests conditionally with annotations like test.skip(), test.fixme(), and test.fail():
// Skip entirely, with a reason that shows up in the report
test.skip(process.env.CI === 'true', 'Flaky on CI runners only, tracked in JIRA-1234');
// Mark as a known failure — Playwright expects it to fail, and flags it
// loudly if it unexpectedly starts passing again
test('legacy export button', async ({ page }) => {
test.fixme(true, 'Broken since the v2 redesign, ticket JIRA-987');
// ...
});
Take any test you've written so far and break it into 3 labelled test.step blocks. Run it with npx playwright test --headed, then open the HTML report afterwards — click into the test and notice each step is now its own expandable row with its own timing.
Built-in & Custom Fixtures
Every time you've written async ({ page }) => { ... }, you've been using a fixture. Fixtures are Playwright's dependency-injection system: instead of manually creating and tearing down objects in every test, you declare what you need and Playwright hands it to you, fully set up, isolated per test.
Built-in fixtures you already know (and a few you don't)
| Fixture | What it gives you |
|---|---|
page | A fresh, isolated page in a fresh browser context |
context | The browser context that owns page — useful for cookies, permissions, multiple tabs |
browser | The shared browser instance (rarely needed directly) |
request | An API request context for pure HTTP calls — covered in Module 8 |
browserName | String: 'chromium', 'firefox', or 'webkit' — useful for browser-specific conditional logic |
Crucially: fixtures are lazy. If a test only declares async ({ page }) => {}, Playwright never bothers spinning up a request context — only the fixtures you actually destructure get created. This is part of why Playwright tests run fast.
Writing your own custom fixture
The real power shows up when you extend test with fixtures of your own — for example, a page that's already logged in, so every test that needs an authenticated user doesn't have to repeat the login steps:
// fixtures.ts
import { test as base, expect } from '@playwright/test';
type MyFixtures = {
loggedInPage: import('@playwright/test').Page;
};
export const test = base.extend<MyFixtures>({
loggedInPage: async ({ page }, use) => {
// --- setup ---
await page.goto('/login');
await page.getByLabel('Email').fill('test@example.com');
await page.getByLabel('Password').fill('correct-horse-battery-staple');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByText('Welcome back')).toBeVisible();
// hand control to the test
await use(page);
// --- teardown (runs after the test finishes, even if it failed) ---
await page.getByRole('button', { name: 'Sign out' }).click();
},
});
export { expect };
// my-test.spec.ts
import { test, expect } from './fixtures';
test('dashboard shows the user name', async ({ loggedInPage }) => {
// no login code here at all — it already happened in the fixture
await expect(loggedInPage.getByText('test@example.com')).toBeVisible();
});
Three things to notice in that pattern: everything before await use(...) is setup, everything after is teardown, and the teardown runs even if the test body throws — same guarantee as a try/finally.
Fixture scope
By default a fixture is created fresh per test ('test' scope). For something expensive that's safe to share, you can widen the scope to 'worker' — created once per parallel worker process and reused across all tests that worker runs:
export const test = base.extend<{}, { accountPool: string[] }>({
accountPool: [async ({}, use) => {
const accounts = await provisionTestAccounts(20); // expensive, do it once
await use(accounts);
await releaseTestAccounts(accounts);
}, { scope: 'worker' }],
});
If 4 tests in the same worker all use the same worker-scoped fixture, they're sharing the exact same object. That's fine for read-only resources like a pool of pre-made accounts, but dangerous for anything one test might mutate in a way that leaks into the next test. When in doubt, default to test-scoped (the default) and only widen scope when you've measured a real performance need.
Fixtures with options
You can also make fixtures configurable per-project via playwright.config.ts, which is how things like baseURL work under the hood. This is advanced and optional for most learners — worth knowing the door exists, not essential to memorize today.
Create a fixtures.ts file with one custom fixture that navigates to your app's homepage before handing back the page (saving every test the trouble of writing page.goto('/')). Update one existing test to import test from your new file instead of @playwright/test directly, and delete its now-redundant goto line.
Page Object Model
The Page Object Model (POM) is a design pattern, not a Playwright feature — but it's the standard way real teams organize larger Playwright suites, so it earns its own lesson. The idea: wrap the locators and actions for one page (or component) into a class, so tests read like business logic instead of raw selector soup.
Without POM — locators scattered across every test
test('login with valid credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('test@example.com');
await page.getByLabel('Password').fill('secret123');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByText('Welcome back')).toBeVisible();
});
test('login with invalid password shows error', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('test@example.com');
await page.getByLabel('Password').fill('wrong');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByText('Invalid credentials')).toBeVisible();
});
If the login form's markup changes, you're hunting through every test file that touches it.
With POM — one class owns the page's locators and actions
// pages/LoginPage.ts
import { Page, Locator, expect } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly signInButton: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.signInButton = page.getByRole('button', { name: 'Sign in' });
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.signInButton.click();
}
async expectError(message: string) {
await expect(this.page.getByText(message)).toBeVisible();
}
}
// login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
test('login with valid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('test@example.com', 'secret123');
await expect(page.getByText('Welcome back')).toBeVisible();
});
test('login with invalid password shows error', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('test@example.com', 'wrong');
await loginPage.expectError('Invalid credentials');
});
Now if the login form changes — say, the email field's label changes from "Email" to "Email address" — there's exactly one line to update, in LoginPage.ts, and every test that uses it is fixed at once.
Combining POM with fixtures
The pattern really shines once you inject page objects as fixtures, so tests never even see a new LoginPage(page) call:
// fixtures.ts
import { test as base } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
export const test = base.extend<{ loginPage: LoginPage }>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
});
Don't reach for POM on day one with a 3-test suite — it's overhead you don't need yet. Introduce a page object the moment you notice yourself copy-pasting the same locators into a second or third test file. Premature abstraction is as real a problem as no abstraction at all.
Putting expect() assertions for business outcomes inside page object methods that should be reusable across many different test scenarios. Keep "pure action" methods (login(), addToCart()) separate from assertion helpers (expectError()) so the same action method can be reused by both a happy-path test and a failure-path test without dragging along an assertion that doesn't apply to both.
Pick the page in your app you've written the most tests against so far. Create a page object class for it with at least 3 locators and 2 action methods, then refactor one existing test to use it instead of inline locators.
Module 7 Quiz
Authentication & storageState
Logging in through the UI before every test works, but it's slow — if login takes 3 seconds and you have 200 tests, that's 10 minutes spent just clicking "Sign in" over and over. Playwright's storageState feature lets you log in once, save the resulting cookies/localStorage to a file, and reuse that saved session across every test instantly.
The core idea
A browser context can save its cookies and localStorage to a JSON file with context.storageState({ path }), and a new context can be born already "logged in" by loading that same file with storageState: 'path/to/file.json'.
The recommended pattern: a setup project
Modern Playwright config supports a dedicated setup project that runs once before your real tests and produces the auth file they all depend on:
// playwright.config.ts (relevant excerpt)
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
],
});
// auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('test@example.com');
await page.getByLabel('Password').fill('secret123');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByText('Welcome back')).toBeVisible();
// Save signed-in state to the file every other project will load from
await page.context().storageState({ path: authFile });
});
With dependencies: ['setup'] in place, Playwright automatically runs the setup project first, once, and every test in the chromium project then starts already authenticated — no login code in any of your actual test files.
Multiple user roles
Real apps usually need to test as more than one type of user (admin vs regular user, free vs paid tier). The pattern extends naturally — save multiple state files and pick the right one per project or per test:
// two setup tests producing two files
const adminFile = 'playwright/.auth/admin.json';
const userFile = 'playwright/.auth/user.json';
setup('authenticate as admin', async ({ page }) => {
// ... log in as admin ...
await page.context().storageState({ path: adminFile });
});
setup('authenticate as regular user', async ({ page }) => {
// ... log in as regular user ...
await page.context().storageState({ path: userFile });
});
// in a specific test file, override storageState just for these tests
test.use({ storageState: 'playwright/.auth/admin.json' });
test('admin can see the billing dashboard', async ({ page }) => {
await page.goto('/admin/billing');
await expect(page.getByRole('heading', { name: 'Billing' })).toBeVisible();
});
The saved storageState JSON file contains live session cookies — treat it like a password. Add playwright/.auth/ to .gitignore, and pull real test credentials from environment variables (process.env.TEST_USER_EMAIL) rather than hardcoding them, even in a learning project. The examples above use obviously-fake placeholder credentials for that reason.
If your app under test has a login form, set up the setup project pattern above end to end. Run your suite and use the trace viewer (next module) or simply add a quick assertion at the top of a test to confirm the page loads already authenticated, with zero login code in that test file.
Waiting Strategies & Flaky Tests
Module 3 covered auto-waiting basics. This lesson goes deeper into the situations where auto-waiting alone isn't enough, and gives you a systematic way to diagnose flaky tests instead of guessing.
waitForLoadState — waiting for the page itself, not an element
await page.goto('/dashboard');
// Wait until there are no network connections for at least 500ms
await page.waitForLoadState('networkidle');
// Other states: 'load' (window.onload fired), 'domcontentloaded' (default for goto)
Pages with polling, analytics beacons, or websockets may never go fully idle, making this wait hang or time out unpredictably. Prefer waiting for a specific element or specific response whenever one is available — reach for networkidle only when there's genuinely no better signal to wait on.
waitForResponse / waitForRequest — waiting on a specific network call
Sometimes a UI action triggers a background API call, and the UI doesn't visibly change until that call resolves. You can wait on the network call directly:
const [response] = await Promise.all([
page.waitForResponse(resp => resp.url().includes('/api/save') && resp.status() === 200),
page.getByRole('button', { name: 'Save' }).click(), // the action that TRIGGERS the request
]);
const body = await response.json();
expect(body.status).toBe('saved');
Writing await page.click(...) THEN await page.waitForResponse(...) as two separate sequential lines can miss the response if the network call resolves faster than your code reaches the second line. The fix is the Promise.all pattern above: start listening for the response and trigger the action in the same breath, so neither one can "win" the race before the other is ready.
A systematic approach to debugging a flaky test
- Reproduce with tracing on. Run the flaky test repeatedly (
--repeat-each=10) withtrace: 'on'and inspect the trace of a failing run (Module 9 covers the trace viewer in depth). - Check for hardcoded waits. Search the test for
waitForTimeout— these are the single most common source of flake, because the "right" delay on your machine isn't the right delay on a slower CI runner. - Check for missing awaits. A forgotten
awaitlets the next line run before the previous async action finishes — Module 4 covered this for locators, but it applies to any async call. - Check for shared state between tests. A test that depends on data left behind by a previous test will pass in isolation but fail when run in a different order or in parallel.
- Check for over-broad locators. A locator that unexpectedly matches more than one element will sometimes interact with the "wrong" one depending on render timing.
Built-in retry as a safety net, not a fix
// playwright.config.ts
export default defineConfig({
retries: process.env.CI ? 2 : 0,
});
It's reasonable to set retries: 2 on CI as a pragmatic safety net against rare infrastructure hiccups. But a test that only passes on its second or third attempt is telling you something real is wrong — track and fix genuinely flaky tests rather than letting retries quietly normalize them.
If you have a test with a waitForTimeout anywhere in it, replace it with a proper wait — either an assertion on a locator that signals "the thing I was waiting for has happened," or a waitForResponse if it's network-driven.
API Testing with request
Playwright isn't only a browser automation tool — it ships a full HTTP client too, via the request fixture. This is useful for two very different jobs: testing an API directly (no browser at all), and using API calls to set up or verify state around a UI test much faster than clicking through the UI would.
Pure API testing — no browser involved
import { test, expect } from '@playwright/test';
test('GET /api/users/1 returns the right user', async ({ request }) => {
const response = await request.get('/api/users/1');
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.name).toBe('Ada Lovelace');
});
test('POST /api/users creates a user', async ({ request }) => {
const response = await request.post('/api/users', {
data: { name: 'Grace Hopper', email: 'grace@example.com' },
});
expect(response.status()).toBe(201);
});
Because there's no browser to launch, these tests run dramatically faster than UI tests — a useful place to put thorough edge-case coverage of your backend without paying browser-launch overhead for every case.
Authenticated requests
test('admin-only endpoint rejects regular users', async ({ request }) => {
const response = await request.get('/api/admin/reports', {
headers: { Authorization: `Bearer ${process.env.REGULAR_USER_TOKEN}` },
});
expect(response.status()).toBe(403);
});
You can also configure a shared base URL and default headers once in playwright.config.ts under use.extraHTTPHeaders, so every request call inherits them automatically.
The real power move: mixing API and UI in one test
Use the API to create exactly the data state you need almost instantly, then switch to the UI only to test the actual UI behavior you care about:
test('user sees their order in the order history page', async ({ page, request }) => {
// Set up state via API — milliseconds, not a multi-step UI flow
const createResponse = await request.post('/api/orders', {
data: { product: 'Widget', quantity: 3 },
});
const order = await createResponse.json();
// Now test the actual thing this test cares about: the UI
await page.goto('/account/orders');
await expect(page.getByText(`Order #${order.id}`)).toBeVisible();
await expect(page.getByText('Widget × 3')).toBeVisible();
});
Only drive a flow through the UI when the UI itself is what you're testing. If a test needs "a user with 5 past orders" purely as a precondition to test something else (like a filter dropdown), creating those 5 orders via API is faster, more reliable, and keeps the test focused on what it's actually verifying.
If your app has any API endpoint, write one pure API test for it with the request fixture — no page at all. Then find one existing UI test whose setup could be replaced by an API call, and try swapping it in.
Network Interception & Mocking
page.route() lets you intercept any network request the page makes and decide what happens to it — let it through unchanged, block it, modify it, or fake an entire response without ever hitting the real server. This is one of Playwright's most powerful and distinctive features.
Blocking requests — e.g. ads, analytics, images, to speed up tests
await page.route('**/*.{png,jpg,jpeg}', route => route.abort());
await page.route('**/analytics.js', route => route.abort());
await page.goto('/'); // loads noticeably faster with images/analytics blocked
Mocking a response entirely — testing UI states that are hard to trigger for real
Want to test what your UI does when an API returns an error, or an empty list, or 500 items, without needing the real backend to be in that exact state? Fake it:
test('shows a friendly error when the orders API fails', async ({ page }) => {
await page.route('**/api/orders', route =>
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal server error' }),
})
);
await page.goto('/account/orders');
await expect(page.getByText('Something went wrong loading your orders')).toBeVisible();
});
test('shows an empty state with zero orders', async ({ page }) => {
await page.route('**/api/orders', route =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) })
);
await page.goto('/account/orders');
await expect(page.getByText('You have no orders yet')).toBeVisible();
});
That error-state test would otherwise require somehow forcing your real backend to fail on command — usually impractical. Mocking makes formerly-untestable UI states trivially testable.
Modifying a real response in flight
await page.route('**/api/feature-flags', async route => {
const response = await route.fetch(); // let the real request happen
const json = await response.json();
json.newCheckoutFlow = true; // force a feature flag on for this test
await route.fulfill({ response, json });
});
Inspecting requests without altering them
page.on('request', request => {
if (request.url().includes('/api/')) {
console.log('>>', request.method(), request.url());
}
});
Tests built entirely on mocked responses can pass even after the real API contract changes, since the mock never has to match reality. Use mocking deliberately for specific hard-to-reach states (errors, edge cases, third-party services you don't control) — keep your core happy-path tests talking to a real backend or a realistic test environment whenever practical.
Pick one API call your app makes and write a test that mocks it to return an error response, asserting your UI shows a sensible error message. Then write a second test mocking an empty-array response and assert the empty state renders correctly.
Module 8 Quiz
Trace Viewer, Codegen & UI Mode
Playwright's debugging tools are a genuine differentiator from most automation frameworks. This lesson is the single most practically useful one in the course — these four tools will save you more time than anything else here.
1. Codegen — record actions, get working code
Codegen opens a real browser, watches everything you click and type, and generates Playwright code for it live:
npx playwright codegen https://playwright.dev
Click around the site in the opened browser; watch the code appear in the companion inspector window. This is excellent for two things: discovering what locator Playwright would naturally pick for an element, and getting a rough first draft of a test fast. It is not a substitute for understanding locators — codegen's auto-picked locator isn't always the most resilient one, so treat its output as a starting draft to refine using what you learned in Module 4, not a finished test.
2. UI Mode — the best way to write and debug tests interactively
npx playwright test --ui
UI Mode opens an interactive window showing every test in your suite, a timeline of every action in the selected test, a live DOM snapshot at each step, and a "watch" feature that re-runs tests automatically as you save the file. For day-to-day test writing, most experienced Playwright users keep UI Mode open instead of running tests headlessly from the terminal — it turns trial-and-error locator tweaking into an instant feedback loop.
3. Trace Viewer — full forensic replay of a test run
Turn tracing on (often already configured to capture on-first-retry by default from npm init playwright):
// playwright.config.ts
use: {
trace: 'on-first-retry', // also: 'on', 'off', 'retain-on-failure'
},
After a run, open any captured trace:
npx playwright show-trace trace.zip
# or open the HTML report and click the trace icon next to a failed test
npx playwright show-report
The trace viewer gives you a scrubbable timeline of the entire test: a screenshot (and DOM snapshot you can inspect like devtools) at every single action, the network requests that happened around each step, console logs, and the exact source line that ran. For a failing test, this is almost always faster than trying to reproduce the failure locally — you're looking at exactly what happened on the machine where it actually failed.
4. VS Code extension
The official Playwright VS Code extension adds: a test explorer sidebar to run/debug individual tests with one click, inline "pick locator" tooling while you're writing a test, breakpoint debugging directly in your test code, and one-click trace viewing for failed runs — all without leaving the editor.
| Tool | Best for |
|---|---|
| Codegen | Quickly discovering a locator, or drafting a brand-new test |
| UI Mode | Day-to-day writing and iterating on tests |
| Trace Viewer | Investigating a specific failure, especially one that happened on CI |
| VS Code extension | Wiring all of the above into your normal editor workflow |
Run npx playwright test --ui right now against any test file you've written. Click through a few tests, watch the timeline and DOM snapshots update, and deliberately break one assertion to see how a failure looks in UI Mode before fixing it back.
Visual Regression Testing
Beyond checking that an element exists or contains certain text, Playwright can compare a screenshot of your page against a saved "baseline" image and fail the test if pixels have changed unexpectedly — catching visual regressions that functional assertions would never notice (a button shifted 20px, a color changed, a font failed to load).
Basic usage
test('homepage looks correct', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage.png');
});
The first time this runs, there's no baseline yet, so Playwright saves the current screenshot as the baseline and the test passes. On every subsequent run, it takes a new screenshot and compares it pixel-by-pixel (with a small configurable tolerance) against that saved baseline — failing if they differ beyond the threshold.
Updating baselines on purpose
When you've made an intentional visual change, you tell Playwright to accept the new look as the new baseline:
npx playwright test --update-snapshots
It's tempting to run --update-snapshots reflexively whenever a visual test fails. Don't — first open the failed test's report, which shows a side-by-side (and overlaid diff) of expected vs actual. Confirm the change is the one you intended before accepting it as the new baseline; otherwise this command quietly approves real regressions.
Masking dynamic content
Real pages often contain content that legitimately changes between runs — a timestamp, an ad, a randomly-rotating banner — which would cause false-positive failures on every run. Mask it out of the comparison:
await expect(page).toHaveScreenshot('dashboard.png', {
mask: [page.getByTestId('last-updated-timestamp'), page.locator('.ad-banner')],
});
Masked regions are painted over with a solid color before comparison, so changes inside them never trigger a failure.
Comparing just one element instead of the whole page
await expect(page.getByTestId('pricing-card')).toHaveScreenshot('pricing-card.png');
Narrower screenshots are more stable (less surface area for unrelated changes to cause failures) and faster to review when they do fail.
Screenshots render very slightly differently across operating systems (font rendering, anti-aliasing) — a baseline generated on macOS will likely fail when compared on Linux CI. If your CI runs on Linux, generate baselines on Linux too (often via a Docker container matching the CI image), or your visual tests will be permanently flaky for reasons that have nothing to do with your app.
Add await expect(page).toHaveScreenshot() to any existing test against a stable page. Run it once to create the baseline, then deliberately change something in your app's CSS and run again to see a real visual-diff failure reported.
Module 9 Quiz
Parallelization, Workers & Sharding
By default Playwright already runs test files in parallel across multiple worker processes — this lesson explains the knobs that control that behavior, and how to spread a very large suite across multiple CI machines.
Workers — parallelism on one machine
// playwright.config.ts
export default defineConfig({
fullyParallel: true, // run tests WITHIN a file in parallel too, not just across files
workers: process.env.CI ? 4 : undefined, // undefined = use roughly half the CPU cores
});
Each worker is an isolated process running its own browser instance. Tests in the same file normally share a worker (and run in order) unless fullyParallel: true is set, in which case Playwright may run even tests within a single file across different workers simultaneously.
If tests in the same file were accidentally relying on running in a specific order (test 2 depending on state left behind by test 1), turning on fullyParallel will surface that as random, confusing failures. This is a feature, not a bug — it's forcing you to fix tests that were never truly independent. Treat any failure that appears only after enabling this flag as a real bug in your test isolation.
Limiting workers for a specific project
# Override the config's worker count for one run, e.g. when debugging flake
npx playwright test --workers=1
Running with --workers=1 is a useful diagnostic step: if a flaky test passes reliably alone but fails under parallel load, that's a strong signal of shared state leaking between tests or workers.
Sharding — splitting one suite across multiple machines
Workers parallelize within a single machine; sharding goes a level further, splitting your entire test suite across multiple separate CI machines/jobs that run concurrently:
# On CI machine 1 of 4:
npx playwright test --shard=1/4
# On CI machine 2 of 4:
npx playwright test --shard=2/4
# ...and so on. Each shard runs roughly a quarter of the total tests.
A 40-minute suite split across 4 shards finishes in roughly 10 minutes wall-clock time (plus some fixed overhead), since the shards run as separate, independent CI jobs in parallel. Most CI providers (GitHub Actions, GitLab CI, CircleCI) support this via a matrix/parallel job configuration that maps directly onto --shard.
Merging shard reports
If you want one combined HTML report instead of 4 separate ones, each shard writes a "blob" report and a final step merges them:
# Each shard:
npx playwright test --shard=1/4 --reporter=blob
# After all shards finish, one final job:
npx playwright merge-reports --reporter=html ./all-blob-reports
Run your suite once normally, then run it again with --workers=1 and compare the total time. Then try --shard=1/2 and --shard=2/2 as two separate commands and notice each one runs roughly half your tests.
CI/CD with GitHub Actions
Back in Module 2, npm init playwright@latest offered to scaffold a GitHub Actions workflow file for you. This lesson explains what that file actually does, line by line, so you're not just trusting a black box.
name: Playwright Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
| Step | What it does |
|---|---|
on: push / pull_request | Defines the triggers — run on every push to main and every PR targeting main |
actions/checkout@v4 | Pulls your repository's code onto the CI runner |
actions/setup-node@v4 | Installs Node.js on the runner |
npm ci | Installs dependencies from package-lock.json exactly — faster and more reproducible than npm install for CI |
playwright install --with-deps | Downloads the actual browser binaries Playwright drives, plus their OS-level dependencies |
npx playwright test | Runs your full suite |
upload-artifact with if: always() | Uploads the HTML report even when tests fail (that's what if: always() guarantees) so you can download and inspect it from the failed run |
Common additions for a real project
- name: Run Playwright tests
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
# combined with a `strategy: matrix:` block above to run shards as parallel jobs
- name: Run Playwright tests
run: npx playwright test
env:
BASE_URL: ${{ secrets.STAGING_URL }}
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
# secrets, never hardcoded credentials, feed into your tests via env vars
Browser binaries don't change often. Most teams add an actions/cache step keyed on the Playwright version, so playwright install only re-downloads binaries when the version actually bumps — shaving a meaningful chunk off every CI run's total time.
If your project lives in a GitHub repo, push the scaffolded workflow file (or the one above) into .github/workflows/playwright.yml, push a commit, and watch it run in the Actions tab. Deliberately break one test, push again, and download the uploaded HTML report artifact from the failed run.
Best Practices & Final Project
This is the last lesson. It pulls everything from Modules 1–10 into one consolidated checklist, then hands you a concrete project to build so the knowledge actually sticks.
The condensed best-practices checklist
| Do | Avoid |
|---|---|
Use getByRole/getByLabel/getByTestId as your default locator strategy | Raw CSS selectors tied to class names or DOM structure |
Let web-first assertions (toBeVisible, toHaveText...) do the waiting for you | waitForTimeout as a way to "make flake go away" |
| Keep each test independent — able to run alone, in any order | Tests that depend on state left behind by a previous test |
Use storageState to log in once and reuse the session | Repeating a full UI login in every single test |
Reach for the request fixture to set up data fast | Driving every precondition through slow UI clicks |
| Introduce Page Objects once duplication actually appears | Building a heavy abstraction layer before you've felt the pain it solves |
Use test.step to keep long tests readable in reports | 200-line tests with no internal structure |
| Investigate flaky tests with the trace viewer | Quietly bumping retries and moving on |
The Final Project
Build a small but complete Playwright suite against a public, stable practice site — no real product required, which means you can start immediately. Two solid options: demo.playwright.dev/todomvc (a classic todo app) or the-internet.herokuapp.com (a large collection of small, deliberately-tricky UI patterns — dropdowns, dynamic loading, iframes, file uploads — great for drilling Module 4/5 skills specifically).
1) Scaffold a fresh project with npm init playwright@latest. 2) Write 6–10 tests covering core flows (for TodoMVC: add/complete/delete/filter/clear-completed items). 3) Organize at least one page into a Page Object class. 4) Add at least one custom fixture. 5) Add test.step labels to your two longest tests. 6) Turn on tracing and deliberately break one test to practice reading a trace. 7) Add one visual regression test with a masked dynamic region if the site has one. 8) Set up the GitHub Actions workflow and push it to a real repo, even an empty throwaway one, just to see it run end to end.
Where to go from here
The Resources view (linked in the sidebar) has curated links to the official docs, the GitHub repo and issue tracker for when you hit something genuinely undocumented, and a few more practice sites. The official Playwright docs are excellent and far more exhaustive than any single course could be — treat this course as the foundation that makes those docs readable, not as the final word.
Before closing this tab for good: actually start the final project. Run npm init playwright@latest in a new folder right now and write your first test against one of the two practice sites above. The gap between "I read a course" and "I built something" closes the moment you write that first line.
Module 10 Quiz — Final Quiz
Cheat Sheet
Bookmark this view. Once you've gone through the full course once, this is the page you'll actually come back to while writing real tests.
Locators — priority order
| getByRole(role, {name}) | 1st choice — matches what users/AT perceive |
| getByLabel(text) | Form fields with a <label> |
| getByPlaceholder(text) | Inputs identified by placeholder |
| getByText(text) | Non-interactive text content |
| getByAltText(text) | Images |
| getByTitle(text) | title= attribute |
| getByTestId(id) | data-testid escape hatch |
| .filter({hasText}) | Narrow ambiguous matches |
| .first() / .last() / .nth(i) | Last resort — order can shift |
Core Actions
| .click() / .dblclick() | Mouse click / double-click |
| .fill(text) | Set input value instantly |
| .pressSequentially(text) | Type key-by-key (triggers JS keydown handlers) |
| .check() / .uncheck() | Checkbox / radio |
| .selectOption(value) | Native <select> |
| .hover() | Mouse hover |
| .dragTo(target) | Drag and drop |
| .setInputFiles(path) | File upload |
| .press(key) | Single key, e.g. 'Enter' |
Assertions
| toBeVisible() / toBeHidden() | Element visibility |
| toBeEnabled() / toBeDisabled() | Interactive state |
| toBeChecked() | Checkbox/radio state |
| toHaveText(str) / toContainText(str) | Exact vs partial text |
| toHaveValue(str) | Input field value |
| toHaveCount(n) | Number of matched elements |
| toHaveURL(str|regex) | Current page URL |
| expect.soft(...) | Collect failures, don't stop immediately |
| expect.poll(fn) | Retry an arbitrary async function |
CLI Commands
| npx playwright test | Run the full suite |
| --headed | Run with a visible browser window |
| --ui | Interactive UI Mode |
| --debug | Step-through debugger |
| --grep @tag | Run only tagged tests |
| --workers=N / --shard=1/4 | Control parallelism |
| codegen <url> | Record actions into code |
| show-report / show-trace | Open the HTML report / a trace file |
| --update-snapshots | Accept new visual baselines |
Config Essentials
| testDir | Where test files live |
| fullyParallel | Parallelize within a file, not just across files |
| retries | Auto-retry failed tests N times |
| use.baseURL | Lets you call page.goto('/path') |
| use.trace | 'on' / 'on-first-retry' / 'retain-on-failure' |
| use.storageState | Start every test pre-authenticated |
| projects | Run the same suite across browsers/devices |
| dependencies: ['setup'] | Run a setup project first (e.g. login) |
Common Patterns (snippets)
| Reuse login | storageState: 'playwright/.auth/user.json' |
| Mock a response | page.route(url, r => r.fulfill({...})) |
| Wait for a network call | Promise.all([page.waitForResponse(...), click]) |
| Group steps | await test.step('label', async () => {...}) |
| Custom fixture | base.extend({ name: async ({page}, use) => {...} }) |
| Visual diff | expect(page).toHaveScreenshot({mask:[...]}) |
Resources & Final Project
Practice sites
TodoMVC (Playwright's official demo)
A classic todo app — perfect for your final project's core flow (add/complete/delete/filter).
The Internet (Herokuapp)
Dozens of small, deliberately tricky UI patterns — dropdowns, iframes, dynamic loading, file uploads, alerts.
playwright.dev itself
The site you've been writing example tests against throughout this course — stable and well-structured.
Official documentation
Playwright Docs
Exhaustive, well-maintained, and far deeper than any single course — this is where you'll live once you're past the basics.
Playwright GitHub repository
Source code, issue tracker, and discussions — useful when you hit something genuinely undocumented or suspect a bug.
Your final project, restated
Build a small Playwright suite (6–10 tests) against TodoMVC or The Internet. Cover the core flows, organize at least one page with the Page Object Model, add one custom fixture, label your two longest tests with test.step, intentionally break and then debug one test using a trace, add one visual regression test with a masked region if applicable, and wire up the GitHub Actions workflow against a real (even throwaway) repository. Full detail is in the last lesson of Module 10 if you want to revisit it.
A closing note
You've now covered everything from "what is a locator" to CI sharding across machines — the same arc that takes most engineers months of trial and error on the job. The gap left isn't more reading; it's hours spent actually writing tests against real, messy pages. Go build the final project, then go point this same toolkit at whatever you're actually testing at work.