09 · Debugging Failing Tests¶
Read the error message first¶
Playwright's timeout errors are unusually informative — resist the urge to
immediately add page.wait_for_timeout(5000) before reading what it says.
playwright._impl._errors.TimeoutError: Locator.click: Timeout 30000ms exceeded.
Call log:
- waiting for get_by_role("button", name="Submit")
- locator resolved to 2 elements. Proceeding with the first one.
- attempting click action
- waiting for element to be visible, enabled and stable
- element is not visible
- retrying click action
- waiting 20ms
...
This tells you three things without a single breakpoint: the locator matched two elements (a strict-mode ambiguity worth fixing on its own), it picked the first, and that first one is present but not visible — pointing you at a CSS/layout issue or a modal covering it, not "the button doesn't exist."
--headed and --slow-mo for visual debugging¶
# opens a real visible browser window, 500ms pause between
# every Playwright action, so you can watch exactly where the
# flow diverges from what you expect
Playwright Inspector: step through interactively¶
# launches the Playwright Inspector alongside the browser —
# a GUI with step/resume/pick-locator controls, pausing before
# the first action so you can single-step the whole test
The Inspector's "pick locator" button lets you click any element in the live page and get back Playwright's own suggested locator for it — useful both for debugging and for writing new locators without guessing.
page.pause() for a targeted breakpoint¶
def test_apply_discount(page):
page.goto("/cart")
page.get_by_placeholder("Discount code").fill("SAVE10")
page.pause() # opens the Inspector right here, mid-test
page.get_by_role("button", name="Apply").click()
# execution halts at page.pause(); the Inspector opens with the
# browser in exactly the state your test left it, so you can
# manually poke at the DOM before deciding what the next
# assertion or action should be
Unlike --headed alone, page.pause() lets you drop a breakpoint at the
exact line you're suspicious of, rather than watching the whole test
play out slowly from the start.
Traces: the most powerful post-mortem tool¶
# opens the Trace Viewer: a timeline scrubber over every action,
# with a DOM snapshot at each step, network requests, console
# logs, and (if enabled) screenshots/video, all correlated
A trace answers "what did the page actually look like right before this failed" far better than a screenshot alone, because you can click any step in the timeline and see the exact DOM state, hover to inspect elements, and check the network tab for that instant — turning an un-reproducible CI-only flake into something you can fully inspect after the fact, once, without re-running anything (Level 4 covers Trace Viewer in full depth, including trace groups and custom annotations).
Console and page errors¶
def test_checkout_flow(page):
page.on("console", lambda msg: print(f"[console] {msg.type}: {msg.text}"))
page.on("pageerror", lambda exc: print(f"[pageerror] {exc}"))
page.goto("/checkout")
[console] error: Failed to load resource: 404 (/api/promo-codes)
[pageerror] TypeError: Cannot read properties of undefined (reading 'code')
A test that fails on a UI assertion is sometimes really failing because of
a JS exception the page itself threw — wiring up console/pageerror
listeners (even just for local debugging, or permanently as an
autouse fixture that fails the test on any pageerror) surfaces the
real root cause instead of a confusing downstream symptom.
Strict mode violations¶
playwright._impl._errors.Error: Locator.click: Error: strict mode
violation: get_by_role("button", name="Delete") resolved to 3 elements:
1) <button>Delete</button> aka locator("li:nth-child(1) >> button")
2) <button>Delete</button> aka locator("li:nth-child(2) >> button")
3) <button>Delete</button> aka locator("li:nth-child(3) >> button")
Strict mode (the default for action methods) refuses to guess which
element you meant — this is a feature, not friction, because it forces you
to scope the locator (row.get_by_role("button", name="Delete")) rather
than silently clicking whichever row happened to render first, which
would pass today and click the wrong row the moment the list re-orders.
A debugging checklist¶
- Read the full error and call log before touching code.
- Reproduce with
--headed --slowmo. - If it's timing/ordering-dependent, reproduce with tracing on and open the trace rather than guessing.
- If a locator is ambiguous, scope it rather than reaching for
.firstas a reflex (.firstcan silently hide a real bug where two elements shouldn't both match). - Check
console/pageerroroutput before assuming the test's assertion, not the app, is wrong. - Only add an explicit wait (
expect(...).to_be_visible()before acting, orpage.wait_for_response(...)) once you know exactly what condition you're waiting for — never a blindwait_for_timeout.
How It Actually Works¶
The call log shown on a timeout isn't Python-side guesswork — it's a live transcript of the actionability retry loop described in Level 1 Module 7, streamed back from the Node driver as it happens. Each line ("locator resolved to 2 elements," "element is not visible") corresponds to one CDP query and one condition evaluation the driver just performed; the log is simply that internal loop's state made visible to you instead of discarded after the fact.
PWDEBUG=1 and page.pause() both work by injecting a debugger
controller into the driver process that intercepts the next outgoing CDP
command and holds it, while opening a separate Inspector window connected
to the same browser via its own CDP session — the Inspector's "pick locator"
feature works exactly like codegen (Level 1 Module 2) in reverse: it
listens for Input.dispatchMouseEvent-level clicks you make directly in
the paused browser window, resolves the clicked node against the
accessibility tree, and computes a locator expression for it.
Trace Viewer's recording is the most involved piece: with tracing on, the
driver subscribes to a wide set of CDP events for the entire test —
Page.screencastFrame (or full DOM snapshots), Network.* events, console
API calls, and every action Playwright itself performs — and serializes
them all, timestamped, into the trace.zip archive. show-trace doesn't
re-run anything against a live browser at all; it loads that recorded event
stream into a static timeline UI, which is exactly why it works fully
offline and can reconstruct the DOM at any point in the trace without ever
reconnecting to the (long since closed) browser that generated it.
Exercise¶
- Take a test that currently passes and deliberately rename a locator's expected text so it fails; read the resulting error's call log line by line and describe, in a comment, exactly what it tells you.
- Run that same failing test with
--tracing=on(force on, not just on failure), open the trace withplaywright show-trace, and step through the timeline to the failing action. - Introduce a genuine strict-mode violation (a locator matching two elements) into a test, observe the exact error Playwright gives, then fix it by scoping the locator to a parent container.
- Add a permanent
autousefixture toconftest.pythat listens forpageerrorevents and fails the test (viapytest.fail(...)) if any uncaught JS exception occurs during the test — run it against a page you know throws one, and confirm the fixture catches it.