| name | Playwright test.step & Reporting |
| description | Teaches the agent to structure tests with test.step, attach evidence and annotations via test.info, use soft assertions, and produce readable, debuggable Playwright HTML reports. |
| version | 1.0.0 |
| author | thetestingacademy |
| license | MIT |
| tags | ["playwright","test-step","reporting","annotations","attachments","soft-assertions","test-info","traceability"] |
| testingTypes | ["e2e","integration"] |
| frameworks | ["playwright"] |
| languages | ["typescript"] |
| domains | ["web"] |
| agents | ["claude-code","cursor","github-copilot","windsurf","codex","aider","continue","cline","zed","bolt","gemini-cli","amp"] |
Playwright test.step & Reporting
This skill makes the agent produce tests that explain themselves in the report. Instead of a flat wall of actions, the agent groups logical phases with test.step, attaches screenshots/JSON/diffs via testInfo.attach, records annotations for traceability, and uses expect.soft to collect multiple failures in one run. When a test fails in CI, a human should understand what happened from the HTML report alone.
Use this skill when writing non-trivial flows, when a test is hard to debug from its output, or when the user mentions reports, steps, annotations, attachments, or issue traceability.
Core Principles
- Every logical phase is a
test.step. Steps appear as a collapsible tree in the HTML report with timings, turning a failure into "which step failed" instead of "which line number."
- Attach evidence, do not just log it.
console.log is invisible in the report; testInfo.attach puts screenshots, JSON, and text into the report next to the step.
- Use
expect.soft to gather multiple defects in one execution, but end critical paths with a hard assertion or expect.poll so the test still fails.
- Annotate for traceability. Link tests to issues/requirements with
annotation, and use test.info().annotations to surface skips/known-issues in the report.
- Steps should be named like a test plan — imperative, business-readable ("Add Pro plan to cart"), not "click button #3".
- Box internal helper steps so a failure points at the caller, not deep inside the helper.
Workflow / Patterns
Pattern 1 — Structure a flow with test.step
Steps nest and report their own duration. Return values from a step to chain them.
import { test, expect } from '@playwright/test';
test('checkout flow', async ({ page }) => {
await test.step('Sign in', async () => {
await page.goto('https://shop.example.com/login');
await page.getByLabel('Email').fill('buyer@example.com');
await page.getByLabel('Password').fill('Secret123!');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByText('Welcome back')).toBeVisible();
});
const orderId = await test.step('Place order for Pro plan', async () => {
await page.getByRole('link', { name: 'Pro plan' }).click();
await page.getByRole('button', { name: 'Buy now' }).click();
await page.getByRole('button', { name: 'Confirm purchase' }).click();
const id = await page.getByTestId('order-id').textContent();
return id!.trim();
});
await test.step(`Verify order ${orderId} in history`, async () => {
await page.goto('https://shop.example.com/orders');
await expect(page.getByText(orderId)).toBeVisible();
});
});
Pattern 2 — Attach evidence with testInfo.attach
Attachments render inline in the HTML report. Attach a screenshot, the API response, or a computed diff at the moment it matters.
test('attaches evidence to the report', async ({ page }, testInfo) => {
await page.goto('https://example.com/dashboard');
await test.step('Capture dashboard state', async () => {
await testInfo.attach('dashboard.png', {
body: await page.screenshot({ fullPage: true }),
contentType: 'image/png',
});
const widgets = await page.evaluate(() => (window as any).__WIDGETS__ ?? []);
await testInfo.attach('widgets.json', {
body: JSON.stringify(widgets, null, 2),
contentType: 'application/json',
});
await testInfo.attach('environment.txt', {
body: `Project: ${testInfo.project.name}\nBase URL: ${page.url()}`,
contentType: 'text/plain',
});
});
});
Pattern 3 — Soft assertions to collect multiple failures
expect.soft records the failure and keeps going. End the test with expect(test.info().errors).toHaveLength(0) or a hard check so it still fails — and the report shows every problem at once.
test('validates a form with soft assertions', async ({ page }) => {
await page.goto('https://example.com/profile');
await test.step('Verify all profile fields at once', async () => {
await expect.soft(page.getByLabel('Display name')).toHaveValue('Ada Lovelace');
await expect.soft(page.getByLabel('Email')).toHaveValue('ada@example.com');
await expect.soft(page.getByLabel('Timezone')).toHaveValue('UTC');
await expect.soft(page.getByRole('img', { name: 'Avatar' })).toBeVisible();
});
expect(test.info().errors).toHaveLength(0);
});
Pattern 4 — Annotations for traceability and known issues
Annotations attach metadata to a test; they show up in the report and JSON output. Use them to link issues and to document why something is skipped.
test('payment retries on gateway 503', async ({ page }) => {
test.info().annotations.push(
{ type: 'issue', description: 'https://github.com/acme/app/issues/4821' },
{ type: 'suite', description: 'payments-regression' },
);
await page.goto('https://example.com/pay');
await expect(page.getByRole('button', { name: 'Pay' })).toBeEnabled();
});
test('legacy export still works', async ({ page }) => {
test.skip(process.env.LEGACY !== 'on', 'Legacy export disabled in this env');
test.info().annotations.push({ type: 'known-issue', description: 'Slow > 5s, tracked in #5102' });
await page.goto('https://example.com/export');
await expect(page.getByText('Export ready')).toBeVisible({ timeout: 30_000 });
});
Pattern 5 — Box helper steps so failures point at the caller
A boxed step collapses its internals in the report; the error is reported at the step call site, not buried in shared helper code.
import { test, expect, type Page } from '@playwright/test';
async function loginAs(page: Page, email: string, password: string) {
await test.step(
`Log in as ${email}`,
async () => {
await page.goto('https://example.com/login');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByTestId('user-menu')).toBeVisible();
},
{ box: true },
);
}
test('admin can open settings', async ({ page }) => {
await loginAs(page, 'admin@example.com', 'Admin123!');
await page.getByRole('link', { name: 'Settings' }).click();
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
});
Pattern 6 — Configure rich reporters
Wire up reporters in playwright.config.ts. The HTML reporter consumes the steps, attachments, and annotations above; JSON/JUnit feed CI dashboards.
import { defineConfig } from '@playwright/test';
export default defineConfig({
reporter: [
['html', { open: 'never', outputFolder: 'playwright-report' }],
['json', { outputFile: 'test-results/results.json' }],
['junit', { outputFile: 'test-results/junit.xml' }],
['list'],
],
use: {
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
});
Best Practices
- Wrap each logical phase in
test.step with a business-readable, imperative name — the report becomes a living test plan.
- Attach the artifact that explains the failure (screenshot, response JSON, diff) at the step where it is relevant, via
testInfo.attach.
- Set
trace: 'retain-on-failure' and screenshot: 'only-on-failure' so failing tests carry full debugging context automatically.
- Use
expect.soft for grouped, independent checks, then add a hard expect(test.info().errors).toHaveLength(0) gate.
- Push
issue/known-issue annotations to keep traceability between tests and tickets visible in the report.
- Box shared helpers so a failure inside login/setup points at the calling test, not the helper internals.
- Return values from steps to pass IDs/tokens forward and keep the flow linear.
Anti-Patterns
- A 60-line test with no steps. A failure gives a line number and nothing else; reviewers cannot tell which phase broke.
console.log for evidence. Logs are not in the HTML report. Use testInfo.attach.
expect.soft everywhere with no hard gate. The test goes green while assertions silently failed. Always end with a hard check.
- Steps named after mechanics ("click", "fill input") instead of intent ("Submit signup form").
- Attaching full-page screenshots on every step of a passing test — bloats the report. Attach on failure or at key checkpoints.
- Skipping tests with bare
test.skip() and no reason — the report shows a skip with no explanation. Always pass a reason string.
- Deep nesting of unnamed anonymous steps that obscure rather than clarify the flow.
When to Trigger This Skill
- "Make my Playwright test report readable / easier to debug"
- "Group my test into steps" / "use
test.step"
- "Attach a screenshot or JSON to the Playwright report"
- "How do I do soft assertions in Playwright?"
- "Add annotations / link tests to Jira or GitHub issues"
- "Why can't I tell which step failed in CI?"
- "Set up the HTML / JUnit / JSON reporter"
- "Capture a trace only when a test fails"