# Debugging Playwright Tests

> **When to use**: A test is failing and you need to understand why — wrong selectors, timing issues, network failures, or unexpected application state.

## Quick Reference

| Tool                 | Command                                      | Best For                                                   |
| -------------------- | -------------------------------------------- | ---------------------------------------------------------- |
| UI Mode              | `npx playwright test --ui`                   | Interactive exploration, visual timeline, re-running tests |
| Playwright Inspector | `PWDEBUG=1 npx playwright test`              | Step-through debugging, selector playground                |
| Trace Viewer         | `npx playwright show-trace trace.zip`        | Post-mortem CI failure analysis                            |
| Headed mode          | `npx playwright test --headed`               | Watching the browser during test execution                 |
| Slow motion          | `npx playwright test --headed --slow-mo=500` | Visually following fast interactions                       |
| `page.pause()`       | Insert in test code                          | Pausing at an exact point to inspect state                 |
| Verbose API logs     | `DEBUG=pw:api npx playwright test`           | Seeing every Playwright API call with timing               |
| VS Code extension    | Playwright Test for VS Code                  | Breakpoints, step-through, pick locator                    |

## Systematic Debugging Workflow

Follow this order. Do not skip to step 5 — most issues resolve by step 2.

```
1. Read the full error message
   └─ Check troubleshooting/error-index.md for known patterns
2. Run with --ui to see what happened visually
   └─ Timeline shows every action, screenshot at failure point
3. Enable tracing if not already on
   └─ use: { trace: 'on' } temporarily in config
4. Check the network tab in trace for API failures
   └─ Missing responses, 4xx/5xx, CORS errors
5. Insert page.pause() at the failure point
   └─ Inspect live DOM, try selectors in console
6. Check browser console for JavaScript errors
   └─ page.on('console') or console tab in trace
```

## Patterns

### Pattern 1: UI Mode for Interactive Debugging

**Use when**: Developing new tests, investigating failures locally, exploring application behavior.
**Avoid when**: CI environments (use traces instead).

UI Mode provides a visual timeline, DOM snapshots at each step, network waterfall, and the ability to re-run individual tests.

**TypeScript**

```typescript
// Launch UI Mode from terminal:
// npx playwright test --ui

// Run a specific test file in UI Mode:
// npx playwright test tests/checkout.spec.ts --ui

// playwright.config.ts — configure for UI Mode convenience
import { defineConfig } from "@playwright/test"

export default defineConfig({
  use: {
    // Traces are always available in UI Mode regardless of this setting,
    // but this ensures traces are captured for CI failures too
    trace: "on-first-retry",
  },
})
```

**JavaScript**

```javascript
// Launch UI Mode from terminal:
// npx playwright test --ui

// Run a specific test file in UI Mode:
// npx playwright test tests/checkout.spec.js --ui

// playwright.config.js
const { defineConfig } = require("@playwright/test")

module.exports = defineConfig({
  use: {
    trace: "on-first-retry",
  },
})
```

### Pattern 2: Playwright Inspector with PWDEBUG

**Use when**: You need to step through actions one at a time, test selectors interactively, or see the exact state before and after each action.
**Avoid when**: The failure is visible from the error message or trace alone.

**TypeScript**

```typescript
// Launch Inspector from terminal:
// PWDEBUG=1 npx playwright test tests/login.spec.ts

// On Windows PowerShell:
// $env:PWDEBUG=1; npx playwright test tests/login.spec.ts

// On Windows CMD:
// set PWDEBUG=1 && npx playwright test tests/login.spec.ts

// Inspector opens automatically. Use these controls:
// - "Step over" button: execute one action at a time
// - "Pick locator" button: hover elements to see the best locator
// - "Resume" button: run to the next page.pause() or end

import { test, expect } from "@playwright/test"

test("debug login flow", async ({ page }) => {
  await page.goto("/login")

  // Inspector pauses before each action when PWDEBUG=1
  await page.getByLabel("Email").fill("user@example.com")
  await page.getByLabel("Password").fill("password123")
  await page.getByRole("button", { name: "Sign in" }).click()

  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
})
```

**JavaScript**

```javascript
// Launch Inspector from terminal:
// PWDEBUG=1 npx playwright test tests/login.spec.js

const { test, expect } = require("@playwright/test")

test("debug login flow", async ({ page }) => {
  await page.goto("/login")

  await page.getByLabel("Email").fill("user@example.com")
  await page.getByLabel("Password").fill("password123")
  await page.getByRole("button", { name: "Sign in" }).click()

  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
})
```

### Pattern 3: Trace Viewer for CI Failure Analysis

**Use when**: A test fails in CI and you need to understand what happened without re-running locally.
**Avoid when**: You can reproduce the failure locally (use UI Mode or Inspector instead).

**TypeScript**

```typescript
// playwright.config.ts — trace configuration
import { defineConfig } from "@playwright/test"

export default defineConfig({
  retries: process.env.CI ? 2 : 0,
  use: {
    // 'on-first-retry' — captures trace on first retry only (recommended for CI)
    // 'on' — captures every run (use temporarily for stubborn failures)
    // 'retain-on-failure' — captures every run, keeps only failures
    trace: "on-first-retry",
  },
})

// After CI failure, download the trace artifact and open it:
// npx playwright show-trace test-results/tests-login-Login-test-chromium/trace.zip

// Or open from URL:
// npx playwright show-trace https://ci.example.com/artifacts/trace.zip

// Or use trace.playwright.dev to view traces in the browser — drag and drop the zip file
```

**JavaScript**

```javascript
// playwright.config.js
const { defineConfig } = require("@playwright/test")

module.exports = defineConfig({
  retries: process.env.CI ? 2 : 0,
  use: {
    trace: "on-first-retry",
  },
})

// After CI failure, download the trace artifact and open it:
// npx playwright show-trace test-results/tests-login-Login-test-chromium/trace.zip
```

**Reading a trace — what to check in order:**

1. **Actions tab** — see every Playwright action with before/after screenshots
2. **Console tab** — browser console output (errors, warnings, logs)
3. **Network tab** — every HTTP request with status, timing, request/response bodies
4. **Source tab** — test source code highlighting the failing line
5. **Call tab** — exact arguments and return values of each Playwright call

### Pattern 4: Headed Mode with Slow Motion

**Use when**: You want to watch the browser during execution without the full Inspector overhead.
**Avoid when**: The test runs too fast to follow even with slow-mo (use Inspector instead).

**TypeScript**

```typescript
// From terminal — quick visual debugging:
// npx playwright test tests/checkout.spec.ts --headed --slow-mo=500

// playwright.config.ts — configure headed mode for local development
import { defineConfig } from "@playwright/test"

export default defineConfig({
  use: {
    // Do NOT commit these — use CLI flags or environment checks instead
    headless: !process.env.HEADED,
    launchOptions: {
      slowMo: process.env.SLOW_MO ? parseInt(process.env.SLOW_MO) : 0,
    },
  },
})

// Then run:
// HEADED=1 SLOW_MO=500 npx playwright test tests/checkout.spec.ts
```

**JavaScript**

```javascript
// From terminal:
// npx playwright test tests/checkout.spec.js --headed --slow-mo=500

// playwright.config.js
const { defineConfig } = require("@playwright/test")

module.exports = defineConfig({
  use: {
    headless: !process.env.HEADED,
    launchOptions: {
      slowMo: process.env.SLOW_MO ? parseInt(process.env.SLOW_MO) : 0,
    },
  },
})
```

### Pattern 5: VS Code Integration

**Use when**: You prefer IDE-based debugging with breakpoints, variable inspection, and integrated test running.
**Avoid when**: You are debugging CI-only failures that do not reproduce locally.

Install the **Playwright Test for VS Code** extension (`ms-playwright.playwright`).

**Key capabilities:**

- **Run/debug individual tests** — click the green play button in the gutter next to any `test()`
- **Set breakpoints** — click the gutter to set breakpoints; tests pause at them automatically
- **Pick locator** — use the "Pick locator" command to hover over elements and get the best selector
- **Show browser** — check "Show Browser" in the testing sidebar to see the browser during execution
- **Watch mode** — enable to re-run tests on file save

**TypeScript**

```typescript
// When debugging in VS Code, use test.only() to focus on one test
// instead of running the entire suite through the debugger
import { test, expect } from "@playwright/test"

test.only("debug this specific test", async ({ page }) => {
  await page.goto("/products")

  // Set a VS Code breakpoint on this line, then inspect `page` in the debug panel
  const productCard = page.getByRole("listitem").filter({ hasText: "Widget" })
  await expect(productCard).toBeVisible()

  await productCard.getByRole("button", { name: "Add to cart" }).click()
  await expect(page.getByTestId("cart-count")).toHaveText("1")
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

test.only("debug this specific test", async ({ page }) => {
  await page.goto("/products")

  const productCard = page.getByRole("listitem").filter({ hasText: "Widget" })
  await expect(productCard).toBeVisible()

  await productCard.getByRole("button", { name: "Add to cart" }).click()
  await expect(page.getByTestId("cart-count")).toHaveText("1")
})
```

### Pattern 6: Capturing Browser Console Logs

**Use when**: Suspecting JavaScript errors, failed client-side API calls, or application-level logging that explains the failure.
**Avoid when**: The issue is clearly a selector or timing problem visible in the trace.

**TypeScript**

```typescript
import { test, expect } from "@playwright/test"

test("capture console output", async ({ page }) => {
  // Collect all console messages
  const consoleLogs: string[] = []
  page.on("console", (msg) => {
    consoleLogs.push(`[${msg.type()}] ${msg.text()}`)
  })

  // Capture uncaught exceptions
  page.on("pageerror", (error) => {
    console.error("Page error:", error.message)
  })

  await page.goto("/dashboard")
  await page.getByRole("button", { name: "Load data" }).click()
  await expect(page.getByRole("table")).toBeVisible()

  // Print collected logs on failure for debugging context
  console.log("Browser console output:", consoleLogs)
})

// Reusable fixture for console logging across all tests
import { test as base } from "@playwright/test"

type ConsoleFixtures = {
  consoleMessages: string[]
}

export const test = base.extend<ConsoleFixtures>({
  consoleMessages: async ({ page }, use) => {
    const messages: string[] = []
    page.on("console", (msg) => messages.push(`[${msg.type()}] ${msg.text()}`))
    page.on("pageerror", (err) => messages.push(`[pageerror] ${err.message}`))
    await use(messages)
  },
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

test("capture console output", async ({ page }) => {
  const consoleLogs = []
  page.on("console", (msg) => {
    consoleLogs.push(`[${msg.type()}] ${msg.text()}`)
  })

  page.on("pageerror", (error) => {
    console.error("Page error:", error.message)
  })

  await page.goto("/dashboard")
  await page.getByRole("button", { name: "Load data" }).click()
  await expect(page.getByRole("table")).toBeVisible()

  console.log("Browser console output:", consoleLogs)
})

// Reusable fixture for console logging
const { test: base } = require("@playwright/test")

const test = base.extend({
  consoleMessages: async ({ page }, use) => {
    const messages = []
    page.on("console", (msg) => messages.push(`[${msg.type()}] ${msg.text()}`))
    page.on("pageerror", (err) => messages.push(`[pageerror] ${err.message}`))
    await use(messages)
  },
})

module.exports = { test }
```

### Pattern 7: Screenshots on Failure

**Use when**: You need a visual snapshot at the exact moment of failure.
**Avoid when**: Traces are enabled (they already include screenshots at every step).

**TypeScript**

```typescript
// playwright.config.ts — automatic screenshots on failure
import { defineConfig } from "@playwright/test"

export default defineConfig({
  use: {
    // 'off' — no screenshots (default)
    // 'on' — screenshot after every test
    // 'only-on-failure' — screenshot only when test fails (recommended)
    screenshot: "only-on-failure",
  },
})
```

```typescript
// Manual screenshot at a specific point
import { test, expect } from "@playwright/test"

test("debug visual state", async ({ page }) => {
  await page.goto("/checkout")
  await page.getByLabel("Promo code").fill("SAVE20")
  await page.getByRole("button", { name: "Apply" }).click()

  // Capture screenshot before assertion for debugging
  await page.screenshot({ path: "test-results/before-discount.png", fullPage: true })

  await expect(page.getByTestId("discount-amount")).toHaveText("-$20.00")
})
```

**JavaScript**

```javascript
// playwright.config.js
const { defineConfig } = require("@playwright/test")

module.exports = defineConfig({
  use: {
    screenshot: "only-on-failure",
  },
})
```

```javascript
// Manual screenshot
const { test, expect } = require("@playwright/test")

test("debug visual state", async ({ page }) => {
  await page.goto("/checkout")
  await page.getByLabel("Promo code").fill("SAVE20")
  await page.getByRole("button", { name: "Apply" }).click()

  await page.screenshot({ path: "test-results/before-discount.png", fullPage: true })

  await expect(page.getByTestId("discount-amount")).toHaveText("-$20.00")
})
```

### Pattern 8: Network Debugging

**Use when**: Suspecting API failures, wrong request payloads, missing auth headers, or slow responses causing timeouts.
**Avoid when**: The trace network tab already shows the problem.

**TypeScript**

```typescript
import { test, expect } from "@playwright/test"

test("debug network requests", async ({ page }) => {
  // Log all requests
  page.on("request", (request) => {
    console.log(`>> ${request.method()} ${request.url()}`)
  })

  // Log all responses with status
  page.on("response", (response) => {
    console.log(`<< ${response.status()} ${response.url()}`)
  })

  // Log failed requests (network errors, not HTTP errors)
  page.on("requestfailed", (request) => {
    console.log(`FAILED: ${request.url()} ${request.failure()?.errorText}`)
  })

  await page.goto("/dashboard")
  await page.getByRole("button", { name: "Refresh" }).click()
  await expect(page.getByRole("table")).toBeVisible()
})

// Wait for a specific API response and inspect it
test("inspect API response", async ({ page }) => {
  await page.goto("/products")

  const responsePromise = page.waitForResponse(
    (resp) => resp.url().includes("/api/products") && resp.status() === 200
  )

  await page.getByRole("button", { name: "Load products" }).click()

  const response = await responsePromise
  const body = await response.json()
  console.log("API response:", JSON.stringify(body, null, 2))

  await expect(page.getByRole("listitem")).toHaveCount(body.products.length)
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

test("debug network requests", async ({ page }) => {
  page.on("request", (request) => {
    console.log(`>> ${request.method()} ${request.url()}`)
  })

  page.on("response", (response) => {
    console.log(`<< ${response.status()} ${response.url()}`)
  })

  page.on("requestfailed", (request) => {
    console.log(`FAILED: ${request.url()} ${request.failure()?.errorText}`)
  })

  await page.goto("/dashboard")
  await page.getByRole("button", { name: "Refresh" }).click()
  await expect(page.getByRole("table")).toBeVisible()
})

test("inspect API response", async ({ page }) => {
  await page.goto("/products")

  const responsePromise = page.waitForResponse(
    (resp) => resp.url().includes("/api/products") && resp.status() === 200
  )

  await page.getByRole("button", { name: "Load products" }).click()

  const response = await responsePromise
  const body = await response.json()
  console.log("API response:", JSON.stringify(body, null, 2))

  await expect(page.getByRole("listitem")).toHaveCount(body.products.length)
})
```

### Pattern 9: Verbose API Logs

**Use when**: You need to see every single Playwright API call with timing to identify where the test is spending time or getting stuck.
**Avoid when**: You already know which action is failing (use Inspector or `page.pause()` instead).

```bash
# See all Playwright API calls with timestamps
DEBUG=pw:api npx playwright test tests/slow-test.spec.ts

# See browser protocol messages (very verbose — use sparingly)
DEBUG=pw:protocol npx playwright test tests/slow-test.spec.ts

# Combine multiple debug channels
DEBUG=pw:api,pw:browser npx playwright test tests/slow-test.spec.ts

# Windows PowerShell
$env:DEBUG="pw:api"; npx playwright test tests/slow-test.spec.ts

# Windows CMD
set DEBUG=pw:api && npx playwright test tests/slow-test.spec.ts
```

### Pattern 10: `page.pause()` — Inline Breakpoints

**Use when**: You need to pause execution at a precise point to inspect the live DOM, try locators, or check application state.
**Avoid when**: You can use `PWDEBUG=1` which pauses at every step automatically.

**TypeScript**

```typescript
import { test, expect } from "@playwright/test"

test("debug with pause", async ({ page }) => {
  await page.goto("/checkout")
  await page.getByLabel("Email").fill("user@example.com")

  // Execution pauses here — Inspector opens with:
  // - Live DOM inspection
  // - Selector playground (try locators in the console)
  // - Step through remaining actions
  await page.pause()

  // These actions wait until you click "Resume" in the Inspector
  await page.getByRole("button", { name: "Continue" }).click()
  await expect(page.getByText("Order confirmed")).toBeVisible()
})
```

**JavaScript**

```javascript
const { test, expect } = require("@playwright/test")

test("debug with pause", async ({ page }) => {
  await page.goto("/checkout")
  await page.getByLabel("Email").fill("user@example.com")

  // Execution pauses here — Inspector opens
  await page.pause()

  await page.getByRole("button", { name: "Continue" }).click()
  await expect(page.getByText("Order confirmed")).toBeVisible()
})
```

**Important**: Remove `page.pause()` before committing. It will hang indefinitely in CI. Use this lint rule or CI check:

```bash
# Add to your pre-commit hook or CI pipeline
grep -r "page.pause()" tests/ && echo "ERROR: Remove page.pause() before committing" && exit 1
```

## Decision Guide

Use this table to pick the right tool based on the failure type.

| Failure Type                           | First Tool                                   | Why                                                                         |
| -------------------------------------- | -------------------------------------------- | --------------------------------------------------------------------------- |
| **Element not found** (selector wrong) | UI Mode (`--ui`)                             | See the DOM at the moment of failure, try selectors in Pick Locator         |
| **Element not found** (timing issue)   | Trace Viewer — Actions tab                   | Compare before/after screenshots to see if element appeared after timeout   |
| **Wrong text / value**                 | Trace Viewer — Actions tab                   | Inspect the actual DOM content at each action step                          |
| **Test hangs / times out**             | `DEBUG=pw:api`                               | See which API call is waiting and never resolving                           |
| **Network / API failure**              | Trace Viewer — Network tab                   | See request/response status codes, payloads, timing                         |
| **Auth / session issues**              | Network debugging (`page.on('response')`)    | Check for 401/403 responses, missing cookies/tokens                         |
| **Visual rendering wrong**             | `--headed --slow-mo=500`                     | Watch the actual rendering in the browser                                   |
| **JavaScript error in app**            | Console logging (`page.on('console')`)       | Catch uncaught exceptions and error logs                                    |
| **CI-only failure**                    | Trace Viewer (from CI artifact)              | Reproduce the exact CI state without running locally                        |
| **Flaky / intermittent**               | Trace on every run (`trace: 'on'`) + retries | Compare passing and failing traces side by side                             |
| **State pollution**                    | Run single test with `test.only()`           | Isolate from other tests; if it passes alone, state leaks from another test |

## Anti-Patterns

### Adding `waitForTimeout` to fix timing issues

```typescript
// WRONG — arbitrary delays mask the real problem and make tests slow and flaky
await page.getByRole("button", { name: "Submit" }).click()
await page.waitForTimeout(3000) // "It works with this delay"
await expect(page.getByText("Success")).toBeVisible()

// RIGHT — wait for the actual condition
await page.getByRole("button", { name: "Submit" }).click()
await expect(page.getByText("Success")).toBeVisible() // Auto-retries for up to 5s
```

If the default timeout is insufficient, investigate _why_ the operation is slow, then either:

- Fix the application performance
- Increase the specific assertion timeout: `await expect(locator).toBeVisible({ timeout: 15000 })`
- Wait for a prerequisite: `await page.waitForResponse('**/api/submit')`

### Commenting out tests to isolate a failure

```typescript
// WRONG — commenting out tests to find which one causes the failure
// test('test A', ...);
// test('test B', ...);
test("test C — this one fails", async ({ page }) => {
  /* ... */
})

// RIGHT — use .only to run a single test
test.only("test C — this one fails", async ({ page }) => {
  /* ... */
})

// RIGHT — use grep to run tests matching a pattern
// npx playwright test --grep "test C"
```

### Not reading the full error message

Playwright error messages include:

- The **expected** vs **actual** value
- The **locator** that was used
- A **call log** showing what Playwright tried before timing out
- The **line number** in your test

Read all of it. The call log alone often shows exactly what went wrong (e.g., "waiting for selector to be visible" when the element exists but is hidden).

### Debugging in CI without traces

```typescript
// WRONG — no traces in CI, no way to debug failures
export default defineConfig({
  use: {
    trace: "off",
  },
})

// RIGHT — always capture traces on failure in CI
export default defineConfig({
  retries: process.env.CI ? 2 : 0,
  use: {
    trace: "on-first-retry",
  },
})
```

### Using `console.log` instead of proper debugging tools

```typescript
// WRONG — sprinkling console.log everywhere
test("debug with logs", async ({ page }) => {
  await page.goto("/dashboard")
  console.log("page loaded")
  const el = page.getByRole("button", { name: "Save" })
  console.log("button found:", await el.isVisible())
  console.log("button text:", await el.textContent())
  // ...20 more console.log calls

  // RIGHT — use page.pause() at the point of interest
  await page.goto("/dashboard")
  await page.pause() // Inspect everything interactively
})
```

### Leaving `page.pause()` or `test.only()` in committed code

```typescript
// WRONG — these should never reach CI
test.only("focused test", async ({ page }) => {
  // Skips all other tests
  await page.goto("/")
  await page.pause() // Hangs forever in CI
})

// Add a CI guard if needed during development
if (!process.env.CI) {
  await page.pause()
}
```

## Troubleshooting

| Symptom                                  | Likely Cause                                    | Fix                                                                                |
| ---------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------- |
| Inspector does not open with `PWDEBUG=1` | Running in headless mode or workers > 1         | Run with `--headed` and `--workers=1`                                              |
| Trace is empty or missing                | `trace: 'off'` in config, or test did not retry | Set `trace: 'on'` temporarily, or `trace: 'retain-on-failure'`                     |
| UI Mode shows stale test results         | File watcher did not pick up changes            | Stop UI Mode, clear `test-results/`, restart                                       |
| `page.pause()` does nothing              | `PWDEBUG` is not set and running headless       | Run with `--headed` or set `PWDEBUG=1`                                             |
| Screenshots are blank or wrong size      | Viewport not set or test runs on wrong project  | Set `viewport` in config; check which browser project ran                          |
| Verbose logs are overwhelming            | Using `DEBUG=pw:protocol`                       | Use `DEBUG=pw:api` for a manageable level of detail                                |
| Trace file is too large                  | `trace: 'on'` for all tests, including passing  | Switch to `trace: 'on-first-retry'` or `trace: 'retain-on-failure'`                |
| VS Code does not detect tests            | Wrong `testDir` or `testMatch` config           | Ensure config paths match and extension settings point to your `playwright.config` |
| Network events not firing                | Request was made before listener was attached   | Attach `page.on('request')` and `page.on('response')` before `page.goto()`         |

## Related

- [core/error-index.md](error-index.md) — look up specific error messages
- [core/flaky-tests.md](flaky-tests.md) — intermittent failure patterns and fixes
- [core/common-pitfalls.md](common-pitfalls.md) — common beginner mistakes
- [core/assertions-and-waiting.md](assertions-and-waiting.md) — web-first assertions and auto-waiting
- [core/configuration.md](configuration.md) — trace, screenshot, and retry configuration
- [ci/reporting-and-artifacts.md](../ci/reporting-and-artifacts.md) — CI artifact collection for traces and screenshots
