Debugging and Troubleshooting Playwright Tests: Pro Tips
A test that passes locally but fails in CI. A selector that worked yesterday but is broken today. A test that passes 90% of the time but occasionally times out. Debugging Playwright tests requires a methodical approach: use the Inspector to step through tests, review traces to see what the DOM looked like when the test failed, and understand common failure patterns. This article covers debugging tools, common issues, and strategies for eliminating flakiness.
Using Playwright Inspector to Step Through Tests
The Playwright Inspector is an interactive debugger that pauses your test and lets you step through actions one at a time, inspecting the DOM and network state at each step:
npx playwright test --debug
This launches the test in debug mode, opening the Inspector window. You can:
- Step through test actions one-by-one (Step button)
- Resume execution (Resume button)
- Jump to a specific line of code
- Inspect the current page state via DevTools console
Example session:
import { test, expect } from '@playwright/test';
test('debug: user login', async ({ page }) => {
await page.goto('/login'); // Click Step → executes this line, shows the login page
// Now step into the next action
await page.locator('[data-testid="email"]').fill('[email protected]');
// Step again
await page.locator('[data-testid="password"]').fill('password');
// Step and watch the API call
await page.locator('role=button[name="Sign In"]').click();
// Step and see if redirect happens
await expect(page).toHaveURL('/dashboard');
});
The Inspector shows the page state after each action, making it obvious where tests diverge from expectations.
Using Traces to Inspect Failed Test State
Traces capture the full execution history: DOM snapshots, network requests, and console logs. When a test fails in CI, download the trace:
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
trace: 'on-first-retry', // Record trace only on first retry (when test fails)
},
});
In CI, after a test fails, the trace is uploaded as an artifact. Download and open it:
npx playwright show-trace path/to/trace.zip
This opens an interactive viewer showing the page at each step, the DOM tree, and network activity. Hover over a step to see what happened at that moment.
Traces are invaluable for debugging flaky tests because they show the exact state when the test failed, not just the error message.
Enabling Verbose Logging
Print detailed logs to understand what's happening:
DEBUG=pw:api npx playwright test
This outputs logs for all Playwright actions:
pw:api await page.goto('http://localhost:3000/login') +1ms
pw:api navigated to http://localhost:3000/login +523ms
pw:api awaiting locator('[data-testid="email"]').fill('[email protected]') +245ms
pw:api filled '[data-testid="email"]' +124ms
Log levels:
DEBUG=pw:api npx playwright test # Only Playwright API calls
DEBUG=pw:api,pw:browser npx playwright test # API + browser events
DEBUG=pw:* npx playwright test # Everything (verbose)
For specific areas:
DEBUG=pw:protocol npx playwright test # Network protocol details
DEBUG=pw:locator npx playwright test # Locator resolution
Slowing Down Test Execution
Use --headed mode and add artificial delays to see what's happening:
npx playwright test --headed --debug-on-failure
Or add explicit pauses in your test:
test('slow-motion debugging', async ({ page }) => {
await page.goto('/login');
// Pause for 2 seconds so you can see the page
await page.pause();
await page.locator('[data-testid="email"]').fill('[email protected]');
// Another pause
await page.pause();
// In headed mode, you'll see the page and have time to inspect it
});
Or run in slow-motion mode:
npx playwright test --headed --slow-mo=1000 # 1 second delay between actions
Troubleshooting Common Issues
Issue 1: "Timeout waiting for locator"
This happens when a selector doesn't match any element, usually because:
- The element hasn't loaded yet (async React rendering)
- The selector is wrong or the element was removed
- The element is inside an iframe or shadow DOM
Solution:
// Bad: selector doesn't exist
await page.locator('[data-testid="nonexistent"]').click(); // times out
// Good: use waitFor with a descriptive message
await page.locator('[data-testid="user-menu"]').waitFor({ timeout: 5000 });
// Or use a more flexible selector
await page.locator('text="Welcome"').click(); // waits up to 30 seconds
// Debug: print the DOM to see what's actually there
console.log(await page.content()); // prints entire HTML
Check the trace or inspector to see what the DOM actually contains.
Issue 2: Race Conditions (test passes locally but fails in CI)
CI environments are often slower, exposing race conditions where your test doesn't wait for async operations:
Bad (race condition):
await page.locator('[data-testid="add-item"]').click();
await expect(page.locator('[data-testid="item"]')).toHaveCount(2); // fails if item hasn't rendered yet
Good (explicit wait):
await page.locator('[data-testid="add-item"]').click();
// Wait for API response
const addPromise = page.waitForResponse(r => r.url().includes('/api/items') && r.status() === 201);
await addPromise;
// Now item should be in DOM
await expect(page.locator('[data-testid="item"]')).toHaveCount(2);
Or (implicit wait via assertion):
await page.locator('[data-testid="add-item"]').click();
// expect() retries for 5 seconds, so it waits for the item to appear
await expect(page.locator('[data-testid="item"]')).toHaveCount(2);
Playwright assertions auto-retry, so use them instead of manual waits when possible.
Issue 3: Flaky Assertion (passes 90% of the time)
Flakiness usually means the test is racing against async operations:
Debug it:
// Add a longer timeout to understand the pattern
test('flaky test with debug', async ({ page }) => {
await page.goto('/dashboard');
// If this times out, the loading takes >30 seconds
const loader = page.locator('[data-testid="loader"]');
// First, verify loader appears
await expect(loader).toBeVisible({ timeout: 2000 });
// Then wait for it to disappear (data loaded)
await expect(loader).not.toBeVisible({ timeout: 10000 });
// Now data should be visible
await expect(page.locator('[data-testid="data"]')).toBeVisible();
});
Fix it:
// Wait for data to load before asserting
const dataPromise = page.waitForResponse(r => r.url().includes('/api/data'));
await dataPromise;
await expect(page.locator('[data-testid="data"]')).toBeVisible();
Issue 4: Screenshot Mismatch in CI (but looks fine locally)
CI might have different system fonts, rendering, or browser versions. Solutions:
// 1. Use looser comparison threshold
await expect(page).toHaveScreenshot('page.png', { maxDiffPixels: 50 });
// 2. Mask dynamic elements
await expect(page).toHaveScreenshot('page.png', {
mask: [page.locator('[data-testid="timestamp"]')],
});
// 3. Use CI-specific baselines
const screenshotName = process.env.CI ? 'page-ci.png' : 'page-local.png';
await expect(page).toHaveScreenshot(screenshotName);
Issue 5: "Target page, context or browser has been closed"
This usually means the page crashed or was closed unexpectedly:
// Listen for page crashes
page.on('close', () => console.log('Page closed unexpectedly'));
page.on('crash', () => console.log('Page crashed'));
// Set a high timeout to catch slow operations
test('slow operation', async ({ page }, testInfo) => {
testInfo.setTimeout(120000); // 2 minutes
await page.goto('/slow-page');
});
Issue 6: "Selector does not resolve to a visible node"
Element exists in DOM but isn't visible (hidden by CSS, outside viewport, etc.):
// Good: explicitly wait for visibility
await expect(page.locator('[data-testid="modal"]')).toBeVisible();
// Or scroll into view first
const element = page.locator('[data-testid="element"]');
await element.scrollIntoViewIfNeeded();
await element.click();
// Debug: check computed visibility
const isVisible = await page.locator('[data-testid="element"]').isVisible();
console.log('Visible:', isVisible);
const element = await page.locator('[data-testid="element"]').elementHandle();
if (element) {
const box = await element.boundingBox();
console.log('Position:', box);
}
Creating Minimal Test Reproductions
When reporting a bug or debugging a flaky test, isolate it to the minimal case:
// Start with a minimal test that reproduces the issue
test('minimal reproduction', async ({ page }) => {
// Only the steps necessary to reproduce
await page.goto('/dashboard');
// Minimal assertion
await expect(page.locator('[data-testid="header"]')).toBeVisible();
});
// Run it 10 times to check flakiness
for (let i = 0; i < 10; i++) {
test(`flaky test run ${i}`, async ({ page }) => {
// copy of the minimal test
});
}
If the minimal test is stable, the issue is in the steps you removed. Add them back one at a time until the test becomes flaky again—that's where the bug is.
Collecting Diagnostics
When a test fails unexpectedly, gather diagnostics:
test('with diagnostics', async ({ page }) => {
try {
await page.goto('/protected');
await expect(page.locator('[data-testid="content"]')).toBeVisible();
} catch (error) {
// Collect info before failing
console.log('URL:', page.url());
console.log('Title:', await page.title());
console.log('DOM:', await page.content());
console.log('Console logs:', await page.evaluate(() => {
// If you've been logging to window.__logs__, retrieve them
return (window as any).__logs || [];
}));
throw error; // Re-throw so test fails as expected
}
});
Performance Profiling
If tests are slow, profile them:
test('profile test performance', async ({ page }) => {
const start = Date.now();
await page.goto('/dashboard');
console.log(`goto: ${Date.now() - start}ms`);
let checkpoint = Date.now();
await page.locator('[data-testid="data"]').waitFor();
console.log(`waitFor: ${Date.now() - checkpoint}ms`);
checkpoint = Date.now();
await expect(page.locator('[data-testid="item"]')).toHaveCount(10);
console.log(`assertion: ${Date.now() - checkpoint}ms`);
console.log(`total: ${Date.now() - start}ms`);
});
Output:
goto: 245ms
waitFor: 512ms
assertion: 89ms
total: 846ms
If goto is slow, the backend is slow. If waitFor is slow, the app takes time to load data. If assertion is slow, something is being retried.
Key Takeaways
- Use
--debugmode to step through tests interactively and inspect DOM state. - Enable traces (
trace: 'on-first-retry') to capture full execution history on failure. - Enable logging (
DEBUG=pw:api) to see what Playwright is doing. - Use
--slow-moand--headedto watch tests and catch subtle issues. - Understand race conditions: use
waitForResponse()and auto-retrying assertions to eliminate flakiness. - Isolate failing tests to minimal reproductions to identify root causes.
Frequently Asked Questions
Why does my test pass 90% of the time?
Flakiness is almost always a race condition: the test assumes something has happened, but timing on CI is slower so it hasn't. Use waitForResponse() or assertions (which auto-retry) instead of manual wait() calls.
How do I speed up test debugging?
Use --headed --slow-mo=500 to watch tests in real time. Use page.pause() to stop at specific points. Don't use --debug for every run—it's slow. Use debug only when you need to step through.
Can I debug tests in VSCode?
Yes, add a debug configuration to .vscode/launch.json:
{
"type": "node",
"request": "launch",
"name": "Playwright Debug",
"runtimeExecutable": "npx",
"runtimeArgs": ["playwright", "test", "--debug"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
}
Then press F5 to start debugging.
How do I ignore flaky tests temporarily?
Use .skip() or test.skip():
test.skip('flaky test that needs investigation', async ({ page }) => {
// This test won't run
});
Or skip conditionally:
test('sometimes flaky test', async ({ page }) => {
test.skip(process.env.CI, 'Skip in CI until fixed');
// ...
});
What if a test is slow but not flaky?
Slow tests are OK (they still pass reliably). Profile them to understand why. Common causes: slow backend, inefficient selectors, too many retries. Optimize if possible, but a reliable slow test is better than a fast flaky one.