← Debugging Guides

阅读中文版本

Playwright test timeout — Debug Locator and Navigation Waits

Diagnose Playwright timeouts with traces, headed debugging, and single-worker runs, then replace fixed sleeps with web-first assertions and stable locators.

intermediatedebuggingplaywrighttesting

Symptoms

  • Error: Test timeout of 30000ms exceeded
  • locator.waitFor: Timeout 30000ms exceeded waiting for getByRole
  • Tests pass locally but time out in CI
  • A test fails only when the suite runs in parallel

Possible causes

  • The locator matches nothing because the accessible name or role changed
  • The application is still loading data and the test asserts immediately
  • A fixed waitForTimeout is used instead of waiting for a condition
  • Shared test data or parallel workers interfere with the same account

Diagnosis steps

Re-run the single test with a trace

npx playwright test tests/checkout.spec.ts --trace on --workers=1

Interpretation

A trace captures DOM snapshots, network, and console for each action. Open it with npx playwright show-trace test-results/<test>/trace.zip to see what the page looked like at failure.

Debug step by step

npx playwright test tests/checkout.spec.ts --debug

Interpretation

The Inspector pauses before each action and highlights the locator. If the element is present but hidden or covered, the actionability check explains why it waits.

Confirm parallel interference

npx playwright test tests/checkout.spec.ts --workers=1 --repeat-each=3

Interpretation

A test that passes serially but fails in parallel is sharing state, not waiting too little. Look for the same user, cart, or database row.

Inspect the failure screenshot and page content

npx playwright show-report && npx playwright test tests/checkout.spec.ts --headed

Interpretation

The report shows the screenshot, error, and call log. A spinner or error toast in the screenshot points to an application failure rather than a locator typo.

Root cause

Playwright auto-waits for elements to be attached, visible, stable, enabled, and able to receive events, but it cannot know when application data has loaded or when a navigation has completed unless the test waits for an observable condition. A locator that no longer matches the DOM, a client-side route that renders after the assertion, or shared state between parallel workers produces a timeout even though the browser is working.

Fix

Use web-first assertions such as expect(locator).toBeVisible() and expect(page).toHaveURL(), prefer role and label locators over CSS chains, and wait for specific responses or UI states instead of fixed sleeps. Give slow legitimate flows a scoped timeout, isolate test data per worker, and avoid depending on execution order.

Risk note

Increasing the global timeout hides races and slows the suite. waitForTimeout makes tests pass briefly and fail under load. Using force clicks or arbitrary selectors bypasses the actionability checks that catch real user-facing bugs.

Verify the fix

Run the suite three times with the CI worker count and confirm the test passes without retries. Re-open the trace for a passing run and verify the element was found and actioned at the expected step, not after an accidental long wait.

Auto-waiting has limits

Playwright waits for an element to be attached, visible, stable, enabled, and receiving events. It does not know when your API response has been rendered into the DOM, so a locator can time out while the page is still showing a skeleton.

Error: locator.click: Timeout 30000ms exceeded.
Call log:
  - waiting for getByRole('button', { name: 'Place order' })

Get evidence, not guesses

npx playwright test tests/checkout.spec.ts --trace on --workers=1
npx playwright show-trace test-results/checkout/trace.zip
npx playwright test tests/checkout.spec.ts --debug

The trace shows the DOM snapshot at failure, network requests, and console errors. The Inspector highlights the locator and explains which actionability check is failing. A screenshot with an error toast usually means the application failed, not the test.

Replace sleeps with conditions

await page.getByRole('button', { name: 'Place order' }).click();
await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible();
await expect(page).toHaveURL(/\/orders\/\d+/);

Use getByRole, getByLabel, and getByTestId instead of long CSS chains. Wait for a specific response when the UI depends on it:

await Promise.all([
  page.waitForResponse((r) => r.url().includes('/api/orders') && r.ok()),
  page.getByRole('button', { name: 'Place order' }).click(),
]);

Verify stability

Run the test three times with --workers=1, then with the CI worker count. It should pass without retries. If it only passes serially, the root cause is shared data: give each worker its own user or tenant rather than raising timeouts.