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',
},
});
| Value | When 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
getByRolefirst. - 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.