Debugging & Troubleshooting Skill
Comprehensive guide to debugging Playwright tests — using Playwright Inspector, debug mode, trace viewer, video analysis, and systematic troubleshooting techniques.
Core Principles
- •Reproduce first - Confirm the failure is consistent before debugging
- •Use built-in tools - Playwright has excellent debugging tools; use them
- •Narrow the scope - Isolate the failing step before investigating
- •Capture evidence - Screenshots, traces, and videos are your best friends
- •Fix root causes - Don't add workarounds without understanding the failure
Table of Contents
- •Playwright Inspector
- •Debug Mode
- •Trace Viewer
- •Video Recording & Analysis
- •Screenshot Debugging
- •Console & Network Debugging
- •Common Failure Patterns
- •CI/CD Debugging
- •Debugging Selectors
- •Systematic Troubleshooting Checklist
Playwright Inspector
Launching the Inspector
bash
# Run a specific test with the inspector npx playwright test tests/checkout.spec.ts --debug # Run all tests in debug mode npx playwright test --debug # Debug a specific test by title npx playwright test -g "should complete checkout" --debug
Using the Inspector Effectively
typescript
test('debug this test with inspector', async ({ page }) => {
await page.goto('/products');
// Add page.pause() to stop execution and open inspector
await page.pause();
// Inspector opens here - you can:
// 1. Step through actions one at a time
// 2. Inspect the page's DOM
// 3. Try selectors in the Locator tab
// 4. View the action log
// 5. Resume or step over
await page.getByRole('button', { name: 'Add to Cart' }).click();
// Add another pause to inspect state after the click
await page.pause();
await expect(page.getByRole('alert')).toBeVisible();
});
Inspector Tips
typescript
// TIP 1: Use PWDEBUG environment variable // PWDEBUG=1 npx playwright test // This auto-opens inspector for ALL tests // TIP 2: Use console mode in inspector // PWDEBUG=console npx playwright test // Opens browser DevTools console with Playwright helpers // TIP 3: Pick locators interactively // In the Inspector, click "Pick locator" then click any element // on the page to get the recommended selector
Debug Mode
Headed Mode for Debugging
bash
# Run tests in headed mode (see the browser) npx playwright test --headed # Slow down actions for visual debugging npx playwright test --headed --slow-mo=500 # Combine with specific test npx playwright test tests/login.spec.ts --headed --slow-mo=1000
Debug Configuration in Code
typescript
// playwright.config.ts - Debug-friendly config
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
// Slow down actions by 200ms (useful for visual debugging)
launchOptions: {
slowMo: process.env.SLOW_MO ? parseInt(process.env.SLOW_MO) : 0,
},
// Keep browser open after test failure
headless: process.env.HEADED ? false : true,
},
});
Debugging a Single Test
typescript
// Temporarily focus on one test for debugging
test.only('debug: should add item to cart', async ({ page }) => {
// Set a longer timeout while debugging
test.setTimeout(0); // No timeout during debugging
await page.goto('/products');
// Log page URL for verification
console.log('Current URL:', page.url());
// Take a screenshot at a specific point
await page.screenshot({ path: 'debug-screenshot.png' });
await page.getByRole('button', { name: 'Add to Cart' }).click();
// Check what's on the page
const content = await page.textContent('body');
console.log('Page contains:', content?.substring(0, 500));
});
// IMPORTANT: Remove test.only before committing!
Trace Viewer
Configuring Trace Collection
typescript
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
// ✅ Recommended: Record trace on first retry
trace: 'on-first-retry',
// Other options:
// trace: 'on' - Always record (slower, more storage)
// trace: 'off' - Never record
// trace: 'retain-on-failure' - Keep only for failures
},
});
Viewing Traces
bash
# Open the trace viewer with a trace file npx playwright show-trace test-results/checkout-should-complete/trace.zip # Open trace from HTML report npx playwright show-report # Then click on a failed test → "Traces" tab
What the Trace Viewer Shows
typescript
// The trace viewer provides: // 1. Timeline - Visual timeline of all test actions // 2. Actions - Each step with before/after screenshots // 3. Metadata - Test name, duration, status // 4. Source - The test source code with current line highlighted // 5. Network - All network requests and responses // 6. Console - Browser console output // 7. DOM - Snapshot of DOM at each step // 8. Call - Detailed info about each Playwright call // You can: // - Click any action to see the page state // - Filter network requests // - Search the DOM snapshot // - View request/response bodies
Programmatic Trace Control
typescript
test('trace specific operations', async ({ page, context }) => {
// Start tracing for a specific section
await context.tracing.start({ screenshots: true, snapshots: true });
await page.goto('/checkout');
await page.getByLabel('Email').fill('user@example.com');
await page.getByRole('button', { name: 'Place Order' }).click();
// Stop and save trace
await context.tracing.stop({ path: 'traces/checkout-trace.zip' });
});
Video Recording & Analysis
Configuring Video Recording
typescript
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
// ✅ Record video only on failure (recommended)
video: 'on-first-retry',
// Video size configuration
video: {
mode: 'on-first-retry',
size: { width: 1280, height: 720 },
},
},
});
Accessing Recorded Videos
typescript
test('video will be saved on failure', async ({ page }, testInfo) => {
await page.goto('/products');
await page.getByRole('button', { name: 'Add to Cart' }).click();
// After test completes, video is available at:
// test-results/<test-name>/video.webm
// You can also access video path programmatically
const video = page.video();
if (video) {
const path = await video.path();
console.log('Video saved at:', path);
// Attach video to test report
await testInfo.attach('test-video', {
path: path,
contentType: 'video/webm',
});
}
});
When to Use Video vs Trace
typescript
// Use VIDEO when: // - You need to see the full visual flow // - Animations or transitions are involved // - You want to share with non-technical stakeholders // - CI/CD failure investigation // Use TRACE when: // - You need to inspect DOM state at each step // - You need network request/response details // - You need console output // - You need step-by-step debugging // - Trace is more detailed but larger files // ✅ Best practice: Use both on first retry // video: 'on-first-retry', // trace: 'on-first-retry',
Screenshot Debugging
Strategic Screenshot Placement
typescript
test('debug with screenshots', async ({ page }, testInfo) => {
await page.goto('/checkout');
// Capture state at key points
await page.screenshot({
path: 'debug/step-1-page-loaded.png',
fullPage: true,
});
await page.getByLabel('Email').fill('user@example.com');
// Capture after form fill
await page.screenshot({
path: 'debug/step-2-form-filled.png',
});
await page.getByRole('button', { name: 'Submit' }).click();
// Capture result
await page.screenshot({
path: 'debug/step-3-after-submit.png',
fullPage: true,
});
// Attach to report for easy access
await testInfo.attach('after-submit', {
body: await page.screenshot(),
contentType: 'image/png',
});
});
Element-Specific Screenshots
typescript
test('capture specific element state', async ({ page }) => {
await page.goto('/dashboard');
// Screenshot just the problematic component
const widget = page.getByTestId('revenue-widget');
await widget.screenshot({ path: 'debug/revenue-widget.png' });
// Screenshot with element highlighted
await page.evaluate(() => {
const el = document.querySelector('[data-testid="revenue-widget"]');
if (el) {
(el as HTMLElement).style.border = '3px solid red';
}
});
await page.screenshot({ path: 'debug/highlighted-widget.png' });
});
Console & Network Debugging
Capturing Console Output
typescript
test('monitor console for errors', async ({ page }) => {
const consoleMessages: string[] = [];
const consoleErrors: string[] = [];
// Capture all console messages
page.on('console', (msg) => {
const text = `[${msg.type()}] ${msg.text()}`;
consoleMessages.push(text);
if (msg.type() === 'error') {
consoleErrors.push(text);
}
});
// Capture uncaught exceptions
page.on('pageerror', (error) => {
consoleErrors.push(`[uncaught] ${error.message}`);
});
await page.goto('/dashboard');
await page.getByRole('button', { name: 'Load Data' }).click();
// Assert no unexpected console errors
const unexpectedErrors = consoleErrors.filter(
(err) => !err.includes('Expected warning') // Filter known warnings
);
expect(unexpectedErrors, `Unexpected console errors: ${unexpectedErrors.join('\n')}`).toHaveLength(0);
});
Monitoring Network Requests
typescript
test('debug API calls', async ({ page }) => {
const apiCalls: Array<{ method: string; url: string; status: number }> = [];
// Log all API responses
page.on('response', (response) => {
if (response.url().includes('/api/')) {
apiCalls.push({
method: response.request().method(),
url: response.url(),
status: response.status(),
});
}
});
// Log failed requests
page.on('requestfailed', (request) => {
console.error(`Request failed: ${request.method()} ${request.url()}`);
console.error(`Reason: ${request.failure()?.errorText}`);
});
await page.goto('/products');
// Verify expected API calls were made
console.log('API calls made:', JSON.stringify(apiCalls, null, 2));
expect(apiCalls.some((call) => call.url.includes('/api/products'))).toBeTruthy();
});
Waiting for Specific Network Requests
typescript
test('wait for API before asserting', async ({ page }) => {
await page.goto('/dashboard');
// ✅ Good - Wait for specific API response before asserting
const [response] = await Promise.all([
page.waitForResponse('**/api/analytics'),
page.getByRole('button', { name: 'Refresh' }).click(),
]);
expect(response.status()).toBe(200);
const data = await response.json();
expect(data.totalRevenue).toBeGreaterThan(0);
});
Common Failure Patterns
Pattern 1: Element Not Found
typescript
// SYMPTOM: "Timeout waiting for selector"
// CAUSE: Selector doesn't match any element
// Debugging steps:
test('debug element not found', async ({ page }) => {
await page.goto('/products');
// Step 1: Check if page loaded correctly
console.log('Page URL:', page.url());
console.log('Page title:', await page.title());
// Step 2: Check if element exists at all
const count = await page.getByRole('button', { name: 'Add to Cart' }).count();
console.log('Matching elements:', count);
// Step 3: Check what's actually on the page
const buttons = await page.getByRole('button').allTextContents();
console.log('All buttons:', buttons);
// Step 4: Try a broader selector
const allText = await page.textContent('body');
console.log('Page text includes "Add":', allText?.includes('Add'));
});
Pattern 2: Timing / Race Condition
typescript
// SYMPTOM: Test passes locally but fails in CI
// CAUSE: Race condition between UI update and assertion
// ✅ Fix: Use proper Playwright waiting
test('fix race condition', async ({ page }) => {
await page.goto('/products');
await page.getByRole('button', { name: 'Add to Cart' }).click();
// ✅ Wait for the specific response that triggers UI update
await page.waitForResponse('**/api/cart');
// ✅ Then assert with auto-waiting
await expect(page.getByRole('alert')).toHaveText('Added to cart');
});
Pattern 3: Stale Element
typescript
// SYMPTOM: "Element is not attached to the DOM"
// CAUSE: Page re-rendered and element reference is stale
// ✅ Fix: Use locators (not element handles)
test('avoid stale element', async ({ page }) => {
await page.goto('/products');
// ✅ Locators always re-query the DOM
const addButton = page.getByRole('button', { name: 'Add to Cart' });
await addButton.click(); // Always finds the latest element
// ❌ Element handles can become stale
// const handle = await page.$('button.add-to-cart');
// await handle.click(); // May fail if DOM re-rendered
});
Pattern 4: Navigation Not Complete
typescript
// SYMPTOM: "Target page, context or browser has been closed"
// CAUSE: Navigation triggered but test didn't wait for it
// ✅ Fix: Wait for navigation explicitly
test('handle navigation', async ({ page }) => {
await page.goto('/login');
// ✅ Wait for navigation triggered by form submit
await Promise.all([
page.waitForURL('**/dashboard'),
page.getByRole('button', { name: 'Login' }).click(),
]);
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
CI/CD Debugging
Getting Debug Info from CI
typescript
// playwright.config.ts - CI-specific configuration
import { defineConfig } from '@playwright/test';
export default defineConfig({
// Retry failed tests in CI
retries: process.env.CI ? 2 : 0,
use: {
// Capture artifacts on failure in CI
screenshot: 'only-on-failure',
trace: 'on-first-retry',
video: 'on-first-retry',
},
// Output directory for artifacts
outputDir: 'test-results/',
// HTML report
reporter: process.env.CI
? [['html', { open: 'never' }], ['github']]
: [['html', { open: 'on-failure' }]],
});
CI Debugging Commands
bash
# Download CI artifacts and view the HTML report npx playwright show-report path/to/downloaded/report # View trace from CI artifacts npx playwright show-trace path/to/trace.zip # Run specific failing test locally with CI-like settings CI=true npx playwright test tests/failing-test.spec.ts --retries=0 --headed
Comparing Local vs CI
typescript
// Add environment info to test output for comparison
test.beforeAll(async () => {
console.log('Environment:', {
ci: process.env.CI,
os: process.platform,
nodeVersion: process.version,
pwVersion: require('@playwright/test/package.json').version,
});
});
Debugging Selectors
Testing Selectors Interactively
bash
# Open a browser to test selectors
npx playwright open https://your-app.com
# Use the Locator picker in the toolbar to find selectors
# Type selectors in the console to test them:
# > page.getByRole('button', { name: 'Submit' })
# > page.getByLabel('Email')
Selector Debugging in Code
typescript
test('debug selectors', async ({ page }) => {
await page.goto('/products');
// List all role-based matches
const buttons = page.getByRole('button');
console.log('Total buttons:', await buttons.count());
for (let i = 0; i < await buttons.count(); i++) {
console.log(`Button ${i}:`, await buttons.nth(i).textContent());
}
// Check if element is hidden vs missing
const submitBtn = page.getByRole('button', { name: 'Submit' });
const isVisible = await submitBtn.isVisible();
const count = await submitBtn.count();
console.log(`Submit button - count: ${count}, visible: ${isVisible}`);
// Use evaluate to inspect element properties
const element = page.getByTestId('product-card');
if (await element.count() > 0) {
const classes = await element.first().getAttribute('class');
console.log('Element classes:', classes);
}
});
Playwright Codegen for Selectors
bash
# Generate test code by interacting with the app npx playwright codegen https://your-app.com # Codegen will: # 1. Open a browser with your app # 2. Record your interactions # 3. Generate Playwright test code with recommended selectors # 4. You can copy selectors directly into your tests
Systematic Troubleshooting Checklist
Step-by-Step Debugging Process
- •
Reproduce the failure
- • Run the failing test in isolation
- • Run it multiple times (is it flaky?)
- • Run in headed mode:
npx playwright test --headed
- •
Gather evidence
- • Check screenshots in
test-results/ - • View trace:
npx playwright show-trace <trace.zip> - • Check video recording
- • Review console output
- • Check screenshots in
- •
Narrow the scope
- • Add
page.pause()before the failing step - • Use Playwright Inspector to step through
- • Check the selector matches elements
- • Verify the page state is correct
- • Add
- •
Identify root cause
- • Is it a selector issue? (element not found)
- • Is it a timing issue? (race condition)
- • Is it a data issue? (missing test data)
- • Is it an environment issue? (CI vs local)
- • Is it a dependency issue? (test order dependent)
- •
Apply the fix
- • Fix the root cause (not a workaround)
- • Add a descriptive error message
- • Verify the fix works in CI
- • Remove any
test.only,page.pause(), orconsole.log
Self-Validation: Verify Tests Aren't Flaky
Before considering a test complete, run it multiple times to confirm stability:
bash
# Run a specific test 5 times to check for flakiness npx playwright test -g "Should display product details" --repeat-each=5 --reporter=line # Run a specific file 5 times npx playwright test tests/checkout/cart.spec.ts --repeat-each=5 --reporter=line # Use the provided hook script for convenience ./hooks/validate-test.sh "Should display product details" ./hooks/validate-test.sh "tests/checkout/cart.spec.ts"
Why 5 times?
- •A test that passes once might be flaky
- •Running 5 times catches most intermittent failures
- •If it passes 5/5, you can be reasonably confident it's stable
- •If it fails even once, investigate before committing
typescript
// ✅ Good - Test is deterministic and passes every time
test('Should display product details', async ({ page }) => {
// Set up via API (not dependent on UI state)
const product = await createTestProduct(page, { name: 'Laptop' });
await page.goto(`/products/${product.id}`);
// Wait for specific API response, not arbitrary timeout
await page.waitForResponse('**/api/products/*');
await expect(page.getByRole('heading', { name: 'Laptop' })).toBeVisible();
});
// ❌ Bad - Test has timing issues that cause flakiness
test('display products', async ({ page }) => {
await page.goto('/products');
await page.waitForTimeout(2000); // Flaky!
await expect(page.getByText('Laptop')).toBeVisible();
});
Quick Reference Commands
bash
# Debug a specific test npx playwright test tests/my-test.spec.ts --debug # Run headed with slow motion npx playwright test --headed --slow-mo=500 # Generate test with codegen npx playwright codegen http://localhost:3000 # View HTML report npx playwright show-report # View trace file npx playwright show-trace test-results/trace.zip # Run with full trace npx playwright test --trace on # List available tests npx playwright test --list