A failing test is a fact: something the runner saw didn't match what your test expected. Debugging is the process of converting that fact into a cause — a wrong locator, a slow API, a CI-only environment quirk. Most teams debug by guessing: "is it flaky? rerun it. is it the cache? clear it. is it the server? restart it." That works until it doesn't, and then a single failure eats a half-day. This lesson is the structured workflow the strongest Playwright teams use — read the error, open the trace, walk the timeline, check the network, check the console, decide on a fix — plus the local debugging tools (page.pause, the VS Code debugger, headed mode) for failures the trace alone can't explain.
The five-step debugging loop
Every failure has a cause. The loop finds it without guessing:
Step 1 — Read the error message carefully. Playwright's errors are dense but truthful. expect(locator).toBeVisible() failed → the element wasn't visible within the timeout. Element is not attached to the DOM → the page navigated and your locator references a stale node. strict mode violation: locator resolved to 3 elements → your selector matches more than one element. Read the full message once before reaching for tools.
Step 2 — Open the trace. From the HTML report or directly:
npx playwright show-trace test-results/<test-name>/trace.zipIf you don't have a trace because tracing is 'off', rerun locally with the flag:
npx playwright test failing-test.spec.ts --trace onStep 3 — Walk the timeline. Click the action right before the failure. Look at the DOM snapshot in the centre panel. Ask:
- Did the page navigate to where you expected?
- Does the element exist?
- Does it have the text/state your assertion checks?
- Is something covering it (a modal, a cookie banner, a loading spinner)?
If the page state at step N is wrong, your bug is at step N — not the assertion that fired at step N+1.
Step 4 — Check the network tab. Filter to the failing time window. Common patterns:
- A
/api/...request that returned 500 — the backend broke, not your test. - A request that returned 200 but with empty data — the API contract changed.
- A request that should have happened but didn't — your test's mock fired but the page never called it (often a routing change).
- A request that took 8 seconds — your test's default 30s timeout will catch it now, but it's a flake risk.
Step 5 — Check the console tab. A red JavaScript error in the page often explains a UI that "looks broken." Uncaught TypeError: Cannot read properties of undefined from a feature flag mismatch is a real example — the flag is set in production but unset in your test environment, the page crashes silently, and your locator times out.
A real example, walked through
A login test passes locally and fails in CI on Firefox only. The error: expect(page.getByText("Welcome back")).toBeVisible() — TimeoutError: Timeout 30000ms exceeded.
- Trace timeline — every step before the assertion succeeded. The page was on
/dashboard. Good. - DOM snapshot at the failing step — the page shows "Welcome, standard_user" — not "Welcome back". The wording changed.
- Source tab — your test was written against the old copy.
- Network tab — confirms
/accountreturned the new payload with afirstNamefield the new UI uses.
The fix is one line: update the assertion to getByText("Welcome, standard_user"). The trace told you everything. Five minutes, not five hours.
Common failure patterns
A short field guide — the same patterns repeat across teams:
Element not found.
The locator doesn't match. Causes: wrong selector (typo, case mismatch), element inside an iframe (use page.frameLocator), element rendered after a network call (add await expect(locator).toBeVisible() before acting), shadow DOM (Playwright pierces it for getByRole etc., but custom CSS selectors don't).
Assertion timeout.
The element is found but the assertion never resolves. Causes: text contains hidden whitespace (toHaveText requires exact match), animation in progress (the element is present but transitioning), the assertion checks a value the page hasn't fetched yet (wait for the network request first), a stale value (the page updated but your locator points to the old node).
Network error.
Causes: API returned an unexpected status, CORS blocked the request in headed mode, your page.route mock returned the wrong shape, the request URL changed (regex too narrow). Filter the network tab by URL and read the response body.
Flaky test (passes sometimes). Race condition between two parallel actions, animation timing, parallel test data collision (two workers hitting the same user account), non-deterministic data (timestamps, random IDs). The fix isn't a sleep — the fix is identifying the dependency and waiting for that explicitly.
Interactive debugging — page.pause
When a trace isn't enough, drop a pause into the test:
import { test, expect } from "@playwright/test";
test("checkout flow", async ({ page }) => {
await page.goto("https://www.saucedemo.com");
await page.getByPlaceholder("Username").fill("standard_user");
await page.getByPlaceholder("Password").fill("secret_sauce");
await page.getByRole("button", { name: "Login" }).click();
await page.pause();
await page.locator(".product").first().click();
});page.pause() halts the test and opens the Playwright Inspector — a docked panel with the page, the test code, a Step button, a Resume button, and a "pick locator" tool. Click around the page; the inspector tells you the recommended Playwright locator for whatever you click. Step through subsequent actions one by one. This is the right tool for "what does the page actually look like when my test gets here?"
Run with npx playwright test --debug to start every test paused.
VS Code debugger
The official Playwright VS Code extension adds a debug profile to every test. Click the green play icon next to the test → "Debug Test". Set breakpoints in your test file with F9, step with F10/F11. Inside a breakpoint you have full Node debugging — inspect the page object, evaluate await page.title() in the debug console, walk the call stack.
For tests that fail in subtle ways (a fixture returning the wrong value, a helper that misformats data), the debugger is faster than the trace.
Headed mode
Some bugs only appear with a real visible browser — focus events, hover-only menus, multi-window flows. Run headed:
npx playwright test --headedOr slow it down so you can actually see what's happening:
npx playwright test --headed --slow-mo=500--slow-mo=500 adds 500ms between every action. Useful for "the test runs too fast to follow" but not for production CI.
The systematic debugging workflow
Step 1 of 8
CI is red — read the failure summary
Look at the assertion message and the line number. Don't open the trace yet — the message often names the cause
Coming from Cypress?
Cypress has .debug(), cy.pause(), the Test Runner's time-travel, and console.log inside tests. Mappings:
cy.pause()→page.pause()— same idea; the Playwright version opens a richer inspector with locator picking..debug()(puts the chained subject onwindow) → no direct equivalent; use VS Code breakpoints withawait page.evaluate(() => debugger)if you need a browser-side breakpoint.- Cypress Runner time-travel → trace viewer, with the upgrade that the trace works for past runs in CI, not just live.
cy.intercept().as('alias').wait('@alias')→await page.waitForResponse('**/api/**')for the same "wait for a specific API call" pattern.
The big shift: Cypress encourages you to debug interactively in the runner; Playwright encourages you to debug the trace of a past run. Both work — the latter is what makes CI failures tractable without re-running everything.
⚠️ Common mistakes
- Adding
await page.waitForTimeout(2000)to "fix" a flake. Sleeps mask the symptom and leave the underlying race in place. Find the dependency (a network call, an animation end, a state change) and wait for it explicitly. - Debugging on
mainwithout a trace. Iftrace: 'off'is configured, every CI failure is a dead lead. Default to'on-first-retry'so you have evidence for any test that fails. - Trusting the screenshot over the DOM snapshot. Screenshots show pixels; DOM snapshots show the actual element tree. A test fails because the locator doesn't match — the screenshot may look fine. Always check the DOM snapshot in the trace viewer, not just the screenshot.
🎯 Practice task
Debug a real failing test using the full workflow. 35-45 minutes.
-
Configure the project to capture traces and retries:
import { defineConfig } from "@playwright/test"; export default defineConfig({ testDir: "./tests", retries: 1, reporter: [["html", { open: "never" }]], use: { trace: "on-first-retry" } }); -
Write a deliberately-broken test that fails for a non-obvious reason:
import { test, expect } from "@playwright/test"; test("inventory page shows 6 items", async ({ page }) => { await page.goto("https://www.saucedemo.com"); await page.getByPlaceholder("Username").fill("standard_user"); await page.getByPlaceholder("Password").fill("wrong-password"); await page.getByRole("button", { name: "Login" }).click(); await expect(page.locator(".inventory_item")).toHaveCount(6); }); -
Run, watch it fail, then
npx playwright show-report. -
Open the trace. Walk the timeline — the DOM snapshot at the post-login step should show the error banner "Username and password do not match" instead of the inventory page. The cause is the wrong password, not a missing element.
-
Now make the test fail for a different reason: change the assertion count to
7. Rerun, open the trace, confirm the count assertion fails and the DOM snapshot shows exactly 6 items. -
Add
await page.pause()after the login click. Rerun headed:npx playwright test --headed. Use the inspector's "pick locator" to target an inventory item. -
Stretch: force a flake. Add a
setTimeoutintests/setup.tsthat randomly delays a fixture by 0-5s. Run with--repeat-each 5and identify the flaky run from the report.
You can now diagnose any Playwright failure in minutes instead of hours. The next lesson tackles the systemic version of debugging — flaky tests that pass and fail without code changes, and the practices that bring a flaky suite back to being trustworthy.