Skip to main content
Playwright intermediate Lesson 8 of 10

Debugging: Traces, UI Mode, and Codegen

Turn a red CI run into an answer: record traces, open them in the viewer, step through with the inspector, and generate a first draft of a test with codegen.

A failing test in CI gives you a stack trace and a screenshot of the end state. A trace gives you the whole run: every action, the DOM before and after it, the network, and the console. Configuring it takes one line and it is the difference between fixing a flake and guessing at it.

Turn on tracing

// playwright.config.ts
export default defineConfig({
  retries: process.env.CI ? 2 : 0,
  use: {
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
});
ValueWhen it records
'off'never
'on'every test — large and slow, use for a single debugging run
'retain-on-failure'every test, kept only if it fails
'on-first-retry'only when a failed test is retried — the usual choice
npx playwright test
Running 24 tests using 4 workers

  ✘  1 [chromium] › tests/orders.spec.ts:14:1 › opens an order (5.6s)
  ✓  1 [chromium] › tests/orders.spec.ts:14:1 › opens an order (retry #1) (2.1s)

  1 flaky
    [chromium] › tests/orders.spec.ts:14:1 › opens an order ─────────────────────
  23 passed (12.4s)

A flaky result is a warning you can act on, and the retry produced a trace:

ls test-results/orders-opens-an-order-chromium-retry1/
trace.zip
video.webm

Open it

npx playwright show-trace test-results/orders-opens-an-order-chromium-retry1/trace.zip
Listening on http://localhost:39471

The viewer has four parts worth knowing:

  • Timeline across the top, one bar per action. The long red bar is your failure.
  • Actions down the left. Click one and the centre pane shows the DOM at that moment.
  • Before / After / Action tabs on the snapshot. “Before” is the state the action saw, which is the pane that answers “why did it click the wrong thing”.
  • Network, Console, Source, Log at the bottom. The Log tab is the same call log the terminal printed, aligned with the timeline.

The snapshots are live DOM, not images — you can hover elements and inspect them. That is what makes a trace better than a video for a locator problem: you can see that the button existed but sat under a modal, which no screenshot would show.

Traces from a CI artifact open the same way, or drag the zip onto trace.playwright.dev, which runs locally in your browser.

Keeping traces from CI

# .github/workflows/e2e.yml
- name: Run Playwright tests
  run: npx playwright test

- uses: actions/upload-artifact@v4
  if: ${{ !cancelled() }}
  with:
    name: playwright-report
    path: playwright-report/
    retention-days: 14
Run actions/upload-artifact@v4
  With the provided path, there will be 38 files uploaded
  Artifact playwright-report has been successfully uploaded!

if: ${{ !cancelled() }} matters — the default if: success() skips the upload on exactly the runs whose report you need. The HTML report embeds the traces, so one artifact is enough.

UI mode

npx playwright test --ui

The best environment for writing tests. It watches files and re-runs on save, shows the trace of the last run inline, and lets you filter by project, tag or status. Two features that are easy to miss:

  • Pick locator — click an element in the snapshot and it produces the locator to use, with the same preference order as getByRole first.
  • Watch mode — the eye icon next to a test re-runs just that test on every save, which turns a locator experiment into a one-second loop.

Stepping through

npx playwright test tests/orders.spec.ts:14 --debug
Running 1 test using 1 worker

The browser opens headed with the Inspector beside it, paused before the first action. Step forward one action at a time; the Inspector highlights the matching elements on the page and logs the call log line by line.

To stop somewhere specific rather than at the start, drop a pause into the test:

test('opens an order', async ({ page }) => {
  await page.goto('/orders');
  await page.getByRole('row').first().click();
  await page.pause();                       // ← inspector opens here
  await expect(page.getByRole('heading')).toHaveText('Order A-1001');
});

page.pause() is a no-op in headless runs, so a forgotten one will not hang CI — but it will make the test pass without asserting if it sits before your assertions. Remove it before committing.

Slowing things down

PWDEBUG=1 npx playwright test tests/orders.spec.ts:14

PWDEBUG=1 opens the Inspector and disables all timeouts, so you can sit on a breakpoint without the test failing underneath you. To watch a run at human speed instead:

// playwright.config.ts
use: { launchOptions: { slowMo: 500 } },

Both are debugging tools, not settings to commit.

Console and page errors

Application errors do not fail a Playwright test by default. Make them visible:

test('no console errors on the dashboard', async ({ page }) => {
  const errors: string[] = [];
  page.on('console', msg => msg.type() === 'error' && errors.push(msg.text()));
  page.on('pageerror', err => errors.push(`uncaught: ${err.message}`));

  await page.goto('/dashboard');
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

  expect(errors).toEqual([]);
});
  ✘  1 [chromium] › tests/dashboard.spec.ts:3:1 › no console errors on the dashboard (1.4s)

    Error: expect(received).toEqual(expected)

    - Expected  - 0
    + Received  + 2

      Array [
    +   "Failed to load resource: the server responded with a status of 404 (/api/prefs)",
    +   "uncaught: Cannot read properties of undefined (reading 'theme')",
      ]

pageerror catches uncaught exceptions, which never reach the console listener. The two together are the closest thing to “did the page actually work”.

Codegen

npx playwright codegen https://demo.playwright.dev/todomvc

A browser opens with a recorder attached; everything you do becomes a test:

import { test, expect } from '@playwright/test';

test('test', async ({ page }) => {
  await page.goto('https://demo.playwright.dev/todomvc');
  await page.getByPlaceholder('What needs to be done?').click();
  await page.getByPlaceholder('What needs to be done?').fill('buy milk');
  await page.getByPlaceholder('What needs to be done?').press('Enter');
  await page.getByRole('listitem').getByRole('checkbox').check();
});

Treat that as a first draft, not a test. It records a click before every fill that you do not need, it has no assertions, and the generated name is test. What it is genuinely good for is discovering locators on an unfamiliar page — record one interaction, keep the locator, throw away the rest.

Record with an existing session:

npx playwright codegen --load-storage=playwright/.auth/user.json https://app.example.com

Practice

1. Set trace: 'on', run one test, and open the trace.
npx playwright test tests/todo.spec.ts --trace on --project=chromium
npx playwright show-trace test-results/todo-adds-a-todo-item-chromium/trace.zip
  ✓  1 [chromium] › tests/todo.spec.ts:11:1 › adds a todo item (1.9s)

  1 passed (2.7s)
Listening on http://localhost:39471

Note the test took 1.9s rather than 1.2s — tracing costs roughly 30-50%, which is why on-first-retry is the default recommendation rather than on.

2. Use the Before snapshot to explain a click that hit the wrong element.
Action: locator.click     getByRole('button', { name: 'Save' })
Before: <div class="modal-backdrop"> covering the viewport
After:  unchanged
Log:    element is not stable — waiting for animations to finish

The Before snapshot shows the backdrop that the screenshot at the end of the test no longer contains. This is the single most common “impossible” failure and the trace answers it in two clicks.

3. Add a console-error listener to an existing test and see whether it stays green.
page.on('pageerror', err => { throw err; });
  ✘  1 [chromium] › tests/orders.spec.ts:4:1 › shows the order list (1.2s)

    Error: Cannot read properties of null (reading 'id')
        at renderRow (http://localhost:3000/assets/app.js:412:19)

Rethrowing inside the handler fails the test at the point the page threw. Start with collecting into an array instead — rethrowing will fail a surprising number of existing tests on the first run.

4. Record a flow with codegen, then rewrite it into a test worth keeping.
test('completing an item updates the counter', async ({ page }) => {
  await page.goto('https://demo.playwright.dev/todomvc');
  const input = page.getByPlaceholder('What needs to be done?');
  await input.fill('buy milk');
  await input.press('Enter');

  await page.getByRole('listitem').getByRole('checkbox').check();

  await expect(page.getByTestId('todo-count')).toHaveText('0 items left');
});
  ✓  1 [chromium] › tests/todo.spec.ts:18:1 › completing an item updates the counter (1.3s)

  1 passed (2.0s)

The redundant click before fill is gone, the test has a name that says what it proves, and it ends with an assertion. Codegen supplied the locators; you supplied the test.

Next: parallelism and configuration — workers, projects, sharding, and what retries hide.

Frequently Asked Questions

What is in a Playwright trace?
Every action with its call log and duration, a DOM snapshot before and after each one, network requests, console output, source, and a screencast if video is on. It is enough to reconstruct the run without re-running it, which is why it is the right artifact to keep from CI.
Should I record traces for every test?
No — `trace: 'on-first-retry'` is the standard setting. Tracing slows tests and produces large files, and a passing test's trace is rarely opened. On the first retry of a failure you get the recording for free on the run that matters.
How do I open a trace file?
Run `npx playwright show-trace trace.zip`, or drag the zip onto trace.playwright.dev, which runs entirely in the browser and uploads nothing. Traces downloaded from a CI artifact open the same way.
What is the difference between UI mode and --debug?
UI mode is a watch-mode runner with a time-travelling trace of the last run, best for writing and exploring tests. `--debug` runs one test with the Inspector attached and the browser headed, pausing on each step, which suits stepping through a specific failure.