skip to content

In Playwright, how do `test.step` and `testInfo.attach` change what the HTML report shows for a test?

level: middleimportance: should knowfreq 48%

answer

  1. Structure, not just more logging
  2. Named sections inside one test
  3. Evidence bound to the result
  4. Failure points at the section it broke in
  5. Content type decides how it renders

basics

~20 s

Steps turn a test into named, timed sections in the report, and a failure is attributed to the step it broke in. Attachments record a named file or string on the test, rendered inline for images and text.

solid answer

~40 s

`await test.step('approve the payroll run', async () => { ... })` wraps part of a test in a named unit. In the report each step becomes a timed row under its test, nested when steps nest, and the error is attributed to the failing step rather than to the test as a whole - so a reader sees where it broke, not just that it broke. `testInfo.attach(name, { path })` or `{ body }` records evidence against the test; Playwright copies it at the moment of the call, and the report lists it by name, rendering images and text inline and offering anything else as a file. `contentType` decides that rendering and is inferred from the file extension when you pass a path. Both travel to every reporter, not just html.

code

typescript · 15 lines
typescript
import { test, expect } from '@playwright/test';

test('March payroll run reconciles to the ledger', async ({ page }, testInfo) => {
  await test.step('open the March payroll run', async () => {
    await page.goto('/payroll/runs/2026-03');
    await expect(page.getByRole('heading', { name: 'March 2026' })).toBeVisible();
  });

  const totals = await test.step('read the on-screen totals', async () => {
    return page.getByTestId('run-totals').innerText();
  });

  await testInfo.attach('run-totals.txt', { body: totals, contentType: 'text/plain' });
  expect(totals).toContain('Net pay');
});

go deeper

for a junior

Know the two calls: test.step names a section of a test, testInfo.attach records a named file or string against it. Both show up in the HTML report without extra configuration.

for a middle

Explain that a step is the same code in the same worker with a label and a duration, that failures are attributed to the step, and that contentType decides whether an attachment renders inline or downloads.

for a senior

Design the test so a failure is diagnosable without a rerun: steps that name business actions, and attachments captured at the moment the state existed rather than reconstructed afterwards.

for a principal

Set the house convention for what gets a step and what gets attached, and weigh report size against triage value - a suite that attaches everything makes artifacts nobody can store or search.

## What a step is `test.step(title, body)` wraps a slice of a test in a named, timed unit: ```ts await test.step('approve the March payroll run', async () => { await page.getByRole('button', { name: 'Approve' }).click(); await expect(page.getByText('Approved')).toBeVisible(); }); ``` The body runs exactly as it would have run unwrapped — same worker, same page, same timeout budget for the test — and `test.step` returns whatever the callback returns, so a step can produce a value the rest of the test uses. Steps nest, so a helper that itself uses `test.step` shows up as a sub-tree. ## What the report does with steps - Each step becomes a named row under its test, with its own duration, so a reader can see which part of a twelve-minute payroll test consumed the time. - A failure is attributed to the step it happened in, which turns "this test failed" into "approval failed after the run loaded". - Nested steps render as a tree, so a page-object method that wraps its own actions reads as one collapsible unit. - `test.step(title, body, { box: true })` boxes a helper: errors from inside are reported at the step's own location rather than deep inside the helper, which is what you want for shared utilities. - The `list` reporter's `printSteps` option prints the same titles in the terminal, so steps are not only an HTML-report feature. ## Attachments: evidence that travels with the result `testInfo.attach(name, options)` records a file or a buffer against the current test. It takes either `{ path }` for a file on disk or `{ body }` for a string or `Buffer`, plus an optional `contentType`. Inside a test you can reach the same object with `test.info().attach(...)`; in a fixture or a hook the `testInfo` argument is already there. Playwright copies the attachment into the run's output at the moment of the call, so the file must exist then — attaching a path that a later cleanup step deletes is fine, attaching one that does not exist yet is not. The attachment then appears by name on that test in the HTML report: images render inline, text is shown, anything else is offered as a file to open. ## Content type decides how it renders | Call | Recorded as | Rendered as | | --- | --- | --- | | `{ body: 'net pay 12,430.00', contentType: 'text/plain' }` | inline text | shown in the report | | `{ path: 'diff.png' }` | a copied file, type inferred from the extension | an inline image | | `{ path: 'gross-to-net.csv' }` | a copied file | a downloadable attachment | | `{ body: buf }` with no `contentType` | a binary blob | a downloadable attachment | Omitting `contentType` is usually fine for a path — it is inferred from the extension — but a string body without one is treated as plain text and a `Buffer` body as opaque bytes, which is why a JSON payload attached as a `Buffer` renders as a download instead of readable text. ## Why this beats `console.log` 1. Console output is a flat stream shared by every test in the worker; an attachment is bound to the test and the attempt that produced it. 2. Console output has no name, no type and no size limit anyone enforces; an attachment has all three and is addressable in the report. 3. Steps give the stream a shape, so the reader does not have to reconstruct the order of operations from timestamps. ## Where else they surface Steps and attachments are not html-only. They are part of the result model every reporter sees: the `json` reporter includes them in its output, the `junit` reporter can embed attachments as a property, and a custom reporter reads `result.attachments` and `result.steps` directly. Two costs are worth naming: very large attachments inflate the report folder the job has to archive, and a step wrapped around a single line adds noise without adding structure.

  • How do you attach evidence from a fixture or an afterEach hook rather than from the test body?
    Both receive `testInfo`, so call `testInfo.attach(...)` there directly; inside a test body without that argument, `test.info().attach(...)` reaches the same object. Fixture teardown is the natural place for evidence you want on every test in a suite, since the test's own code does not have to remember to record it.
  • What does the box option on test.step change about a failure?
    `test.step(title, body, { box: true })` reports an error thrown inside the step at the step's own location rather than deep inside the helper that threw it. It is meant for shared page-object helpers, where the useful location is the call site in the test, not a line inside a utility file.

Steps are chapter headings in the test's story and attachments are the appendix: the reader can see which chapter failed and still open the exhibit that proves it.

saying these in an interview costs you the question

  • Thinks test.step retries or isolates the code it wraps
  • Uses console.log for evidence instead of an attachment
  • Attaches a file path that does not exist yet
  • Believes attachments only show up in the trace viewer
  • Wraps every single line in a step, adding noise not structure