☰ menu
Playwright Course  /  Welcome
Cheat Sheet
A self-contained, self-paced course

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.

10Modules
28Lessons
10Quizzes
60+Code examples
0Prior experience required

How this platform works

01

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.

02

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.

03

Watch for the colored callout boxes

Cyan = tips. Amber = gotchas. Coral = common mistakes people make at your stage. Lime = exercises to try yourself.

04

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.

▸ Before you start

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.

Module 1 · Orientation

Welcome & How This Course Works

Lesson 1 of 3 · ~4 min read

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

  1. 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.
  2. 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.
  3. 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.
▸ Suggested pace

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

ElementWhat it means
code blocksReal, runnable Playwright code. Copy the filename comment too — it tells you where to save it.
Cyan tip boxA shortcut, mental model, or "good to know" that isn't strictly required but will save you time.
Amber gotcha boxA subtlety that trips up almost everyone once. Read these twice.
Coral mistake boxA specific anti-pattern beginners write, why it fails, and the fix.
Lime exercise boxA 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 --debug or 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.
▸ Try it yourself — before Module 2

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.

Module 1 · Orientation

What Is Playwright, and Why It Matters

Lesson 2 of 3 · ~6 min read

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.
▸ Mental model

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 properly
import { 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.

▸ Try it yourself

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.

Module 1 · Orientation

Playwright vs Selenium vs Cypress

Lesson 3 of 3 · ~5 min read

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 WebDriverCypressPlaywright
ArchitectureTalks 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-browserBroadest 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-waitingLargely 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-originSupported, 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.
ParallelizationPossible 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 toolsMostly 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.
LanguagesJava, Python, C#, JavaScript, Ruby, and more.JavaScript/TypeScript only.JavaScript/TypeScript, Python, Java, .NET.
API testingNot built in.Possible but secondary to UI testing.First-class request context for pure API testing, usable alongside or independent of UI tests.
▸ A fair caveat

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 · Check Your Understanding

Module 1 Quiz

4 questions · instant feedback
Module 2 · Setup & First Test

Installing Node.js & Playwright

Lesson 1 of 3 · ~6 min, hands-on

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 install
node -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:

terminal
mkdir 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:

PromptRecommended 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.
▸ If the browser download fails or is slow

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.

▸ Install the VS Code extension too

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.

▸ Try it yourself

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.

Module 2 · Setup & First Test

Project Tour & the Config File

Lesson 2 of 3 · ~7 min read

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'] } },
  ],
});
OptionWhy it matters
testDirChange this if you organize tests outside the default tests/ folder.
retriesA pragmatic flakiness buffer for CI — locally you usually want 0 so failures are obvious immediately.
use.baseURLSet 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.traceThe trace viewer (Module 9) is Playwright's best debugging tool — leave this on at least for retries.
projectsEach 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).
▸ You can run a single project

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.

Module 2 · Setup & First Test

Your First Test, Line by Line

Lesson 3 of 3 · ~7 min, hands-on

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. test defines a test case; expect makes 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: page is "the browser tab you're controlling."
  • await page.goto(...) — every Playwright action that touches the browser is asynchronous, so it's awaited. Forgetting await is 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 plain if check, 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
▸ Common mistake

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.

▸ Try it yourself

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 · Check Your Understanding

Module 2 Quiz

4 questions · instant feedback
Module 3 · Core Concepts

Browser → Context → Page

Lesson 1 of 3 · ~5 min read

Three objects sit underneath almost everything you'll write. Understanding the hierarchy explains a lot of Playwright's behavior that would otherwise feel arbitrary.

ObjectReal-world equivalentCost to create
BrowserThe actual browser application (Chrome, Firefox, Safari) running as a process.Expensive — launched once and reused across many tests.
BrowserContextAn isolated "incognito window" — its own cookies, local storage, and cache.Cheap — created fresh per test in milliseconds, fully isolated.
PageA 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.

▸ Why isolation matters for parallel testing

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.

▸ Gotcha

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.

Module 3 · Core Concepts

Auto-Waiting: Playwright's Superpower

Lesson 2 of 3 · ~6 min read

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):

  1. Attached — the element exists in the DOM.
  2. Visible — it has non-zero size and isn't hidden via CSS.
  3. Stable — it isn't mid-animation (its bounding box hasn't moved in two consecutive frames).
  4. Receives events — it isn't covered by another element (like a modal overlay or loading spinner).
  5. 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.
▸ This applies to assertions too

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.
▸ Common mistake

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.

▸ Try it yourself

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.

Module 3 · Core Concepts

Anatomy of a Test File

Lesson 3 of 3 · ~6 min read

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}`);
    }
  });

});
BlockPurpose
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.
▸ beforeAll vs beforeEach — pick correctly

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.

▸ Try it yourself

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 · Check Your Understanding

Module 3 Quiz

4 questions · instant feedback
Module 4 · Locators

getBy* — The Recommended Way to Find Elements

Lesson 1 of 3 · ~8 min read

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:

LocatorFinds elements by...Example
getByRoleARIA role + accessible name — closest to how a screen reader "sees" the page. First choice for almost everything.page.getByRole('button', { name: 'Sign in' })
getByLabelThe <label> text associated with a form field.page.getByLabel('Email address')
getByPlaceholderAn input's placeholder attribute.page.getByPlaceholder('Search...')
getByTextVisible text content anywhere on the page.page.getByText('Welcome back')
getByAltTextAn image's alt attribute.page.getByAltText('Company logo')
getByTitleAn element's title attribute (tooltip text).page.getByTitle('Close dialog')
getByTestIdA dedicated data-testid attribute — the deliberate escape hatch for elements with no good accessible identity.page.getByTestId('cart-total')
▸ Why role-based locators win

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"
▸ Don't guess roles — inspect them

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.

▸ Try it yourself

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.

Module 4 · Locators

CSS, XPath, Chaining & Filtering

Lesson 2 of 3 · ~7 min read

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
▸ Gotcha

.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();
▸ Try it yourself

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.

Module 4 · Locators

Locator Best Practices & Common Mistakes

Lesson 3 of 3 · ~6 min read

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-testid attributes 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 await on actions — a missing await doesn'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.
▸ The #1 beginner mistake: missing await
// 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?"

  1. Is it a button, link, heading, checkbox, or other standard interactive/semantic element? → getByRole.
  2. Is it a form field with a visible label? → getByLabel.
  3. Does it have unique, stable visible text and no role? → getByText.
  4. Is it an icon-only control or genuinely has nothing identifying about it? → ask for (or add) a data-testid, then use getByTestId.
  5. Only as a last resort — CSS or XPath, and leave a comment explaining why.
▸ Try it yourself

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 · Check Your Understanding

Module 4 Quiz

5 questions · instant feedback
Module 5 · Actions

Core Actions

Lesson 1 of 2 · ~7 min read

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() vs pressSequentially()

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.

▸ Try it yourself

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.

Module 5 · Actions

Advanced Actions: Hover, Drag, Upload, Tabs, iFrames & Dialogs

Lesson 2 of 2 · ~8 min read

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 chains like normal locators

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();
▸ Common mistake

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.

▸ Try it yourself

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 · Check Your Understanding

Module 5 Quiz

4 questions · instant feedback
Module 6 · Assertions

expect() & Web-First Assertions

Lesson 1 of 2 · ~6 min read

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

TypeExampleRetries automatically?
Element (locator)expect(locator).toBeVisible()Yes
Pageexpect(page).toHaveURL(...)Yes
Plain valueexpect(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.

▸ Common mistake
// 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');
▸ Try it yourself

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.

Module 6 · Assertions

Soft Assertions, Polling & Custom Matchers

Lesson 2 of 2 · ~5 min read

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
▸ Use soft assertions sparingly

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();
▸ Try it yourself

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 · Check Your Understanding

Module 6 Quiz

4 questions · instant feedback
Module 7 · Organization & Fixtures

Hooks, test.step & Tags

Lesson 1 of 3 · ~6 min read

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

HookRunsTypical use
beforeAllOnce, before all tests in the file/describe blockExpensive one-time setup (seed a database, start a server)
beforeEachBefore every single testNavigate to a starting page, log in, reset state
afterEachAfter every single testTake a screenshot on failure, clean up created data
afterAllOnce, after all tests in the file/describe blockTear down shared resources from beforeAll
▸ beforeAll runs in a shared, non-isolated context

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');
  // ...
});
▸ Try it yourself

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.

Module 7 · Organization & Fixtures

Built-in & Custom Fixtures

Lesson 2 of 3 · ~8 min read

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)

FixtureWhat it gives you
pageA fresh, isolated page in a fresh browser context
contextThe browser context that owns page — useful for cookies, permissions, multiple tabs
browserThe shared browser instance (rarely needed directly)
requestAn API request context for pure HTTP calls — covered in Module 8
browserNameString: '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' }],
});
▸ Worker-scoped fixtures are shared, so don't mutate shared state carelessly

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.

▸ Try it yourself

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.

Module 7 · Organization & Fixtures

Page Object Model

Lesson 3 of 3 · ~7 min read

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));
  },
});
▸ A practical rule of thumb

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.

▸ Common mistake

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.

▸ Try it yourself

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 · Check Your Understanding

Module 7 Quiz

4 questions · instant feedback
Module 8 · Real-World Patterns

Authentication & storageState

Lesson 1 of 4 · ~7 min read

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();
});
▸ Never commit real credentials or auth files to version control

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.

▸ Try it yourself

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.

Module 8 · Real-World Patterns

Waiting Strategies & Flaky Tests

Lesson 2 of 4 · ~8 min read

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)
▸ networkidle is a tool of last resort, not a default habit

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');
▸ Common mistake — the race condition trap

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

  1. Reproduce with tracing on. Run the flaky test repeatedly (--repeat-each=10) with trace: 'on' and inspect the trace of a failing run (Module 9 covers the trace viewer in depth).
  2. 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.
  3. Check for missing awaits. A forgotten await lets the next line run before the previous async action finishes — Module 4 covered this for locators, but it applies to any async call.
  4. 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.
  5. 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,
});
▸ Retries hide symptoms; they don't cure causes

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.

▸ Try it yourself

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.

Module 8 · Real-World Patterns

API Testing with request

Lesson 3 of 4 · ~7 min read

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();
});
▸ A guiding principle

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.

▸ Try it yourself

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.

Module 8 · Real-World Patterns

Network Interception & Mocking

Lesson 4 of 4 · ~7 min read

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());
  }
});
▸ Mocking is a scalpel, not a default

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.

▸ Try it yourself

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 · Check Your Understanding

Module 8 Quiz

5 questions · instant feedback
Module 9 · Debugging & Visual Testing

Trace Viewer, Codegen & UI Mode

Lesson 1 of 2 · ~8 min read

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.

ToolBest for
CodegenQuickly discovering a locator, or drafting a brand-new test
UI ModeDay-to-day writing and iterating on tests
Trace ViewerInvestigating a specific failure, especially one that happened on CI
VS Code extensionWiring all of the above into your normal editor workflow
▸ Try it yourself

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.

Module 9 · Debugging & Visual Testing

Visual Regression Testing

Lesson 2 of 2 · ~6 min read

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
▸ Review the diff before updating, every time

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.

▸ Baselines are platform-specific

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.

▸ Try it yourself

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 · Check Your Understanding

Module 9 Quiz

4 questions · instant feedback
Module 10 · Scaling Up

Parallelization, Workers & Sharding

Lesson 1 of 3 · ~6 min read

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.

▸ fullyParallel exposes hidden test interdependencies

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
▸ Try it yourself

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.

Module 10 · Scaling Up

CI/CD with GitHub Actions

Lesson 2 of 3 · ~6 min read

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
StepWhat it does
on: push / pull_requestDefines the triggers — run on every push to main and every PR targeting main
actions/checkout@v4Pulls your repository's code onto the CI runner
actions/setup-node@v4Installs Node.js on the runner
npm ciInstalls dependencies from package-lock.json exactly — faster and more reproducible than npm install for CI
playwright install --with-depsDownloads the actual browser binaries Playwright drives, plus their OS-level dependencies
npx playwright testRuns 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
▸ Cache the browser binaries to speed up every run

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.

▸ Try it yourself

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.

Module 10 · Scaling Up

Best Practices & Final Project

Lesson 3 of 3 · ~9 min read

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

DoAvoid
Use getByRole/getByLabel/getByTestId as your default locator strategyRaw CSS selectors tied to class names or DOM structure
Let web-first assertions (toBeVisible, toHaveText...) do the waiting for youwaitForTimeout as a way to "make flake go away"
Keep each test independent — able to run alone, in any orderTests that depend on state left behind by a previous test
Use storageState to log in once and reuse the sessionRepeating a full UI login in every single test
Reach for the request fixture to set up data fastDriving every precondition through slow UI clicks
Introduce Page Objects once duplication actually appearsBuilding a heavy abstraction layer before you've felt the pain it solves
Use test.step to keep long tests readable in reports200-line tests with no internal structure
Investigate flaky tests with the trace viewerQuietly 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).

▸ Suggested scope (aim for a focused afternoon, not a marathon)

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.

▸ One last try-it-yourself

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 · Check Your Understanding

Module 10 Quiz — Final Quiz

5 questions · instant feedback
Quick Reference

Cheat Sheet

Everything from Modules 1–10, condensed onto one page

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 testRun the full suite
--headedRun with a visible browser window
--uiInteractive UI Mode
--debugStep-through debugger
--grep @tagRun only tagged tests
--workers=N / --shard=1/4Control parallelism
codegen <url>Record actions into code
show-report / show-traceOpen the HTML report / a trace file
--update-snapshotsAccept new visual baselines

Config Essentials

testDirWhere test files live
fullyParallelParallelize within a file, not just across files
retriesAuto-retry failed tests N times
use.baseURLLets you call page.goto('/path')
use.trace'on' / 'on-first-retry' / 'retain-on-failure'
use.storageStateStart every test pre-authenticated
projectsRun the same suite across browsers/devices
dependencies: ['setup']Run a setup project first (e.g. login)

Common Patterns (snippets)

Reuse loginstorageState: 'playwright/.auth/user.json'
Mock a responsepage.route(url, r => r.fulfill({...}))
Wait for a network callPromise.all([page.waitForResponse(...), click])
Group stepsawait test.step('label', async () => {...})
Custom fixturebase.extend({ name: async ({page}, use) => {...} })
Visual diffexpect(page).toHaveScreenshot({mask:[...]})
Keep Going

Resources & Final Project

Where to practice, where to dig deeper, what to build next

Practice sites

TodoMVC (Playwright's official demo)

A classic todo app — perfect for your final project's core flow (add/complete/delete/filter).

Open →

The Internet (Herokuapp)

Dozens of small, deliberately tricky UI patterns — dropdowns, iframes, dynamic loading, file uploads, alerts.

Open →

playwright.dev itself

The site you've been writing example tests against throughout this course — stable and well-structured.

Open →

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.

Open →

Playwright GitHub repository

Source code, issue tracker, and discussions — useful when you hit something genuinely undocumented or suspect a bug.

Open →

Your final project, restated

▸ The brief

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.