Resource library

QA How-To

Playwright test.step: Examples and Best Practices

Use playwright test.step examples for typed data, page objects, decorators, boxed errors, timeouts, attachments, soft assertions, and clear test reports.

16 min read | 2,377 words

TL;DR

Build reports around stable business operations, then choose advanced features based on diagnosis needs. Returned values improve data flow, boxed helpers clarify ownership, timeouts bound phases, and step attachments keep evidence close to the failing operation.

Key Takeaways

  • Map long scenarios to a few domain phases rather than one step per Playwright action.
  • Return typed setup results from steps and consume them in later verification phases.
  • Wrap public page object operations while leaving locator getters unwrapped.
  • Use decorators only in repositories that already support and understand the TypeScript model.
  • Associate sanitized API and visual evidence with the step that produced it.
  • Use soft assertions and conditional skips only when later checks remain independently valuable.

Playwright test.step examples are most effective when they turn a long automation script into a short narrative of user intent. A step should group an operation that a reviewer can recognize, while the report still preserves the locator actions and assertions inside it.

This cookbook moves beyond a basic wrapper. It covers arrange-act-assert structure, API-to-UI workflows, returned values, page objects, boxed errors, targeted timeouts, step attachments, conditional skips, soft assertions, and reporter-friendly naming. All runner APIs shown are part of current Playwright Test.

TL;DR

Pattern Use it for Avoid when
Three top-level scenario steps A readable end-to-end narrative The test has only one action and assertion
Step-returned value Passing created IDs or models The value outlives its fixture or page
Page object step Stable domain operation A low-level locator getter
Boxed step Caller-focused helper failures Internal helper lines are more actionable
Timed step A bounded business phase You are guessing at a general timeout fix
Step attachment Focused evidence for one phase Global artifacts already contain the same data
Conditional step skip Optional independent verification Later behavior depends on the skipped work

Use await test.step('Verb plus business outcome', async step => { ... }, options). Keep steps shallow, stable, and free of secrets.

1. Playwright test.step Examples With Arrange, Act, and Assert

A useful first pattern maps test intent into three phases without naming them literally "Arrange," "Act," and "Assert." Domain titles give the report more context.

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

test('customer updates the delivery address', async ({ page, request }) => {
  const customerId = await test.step('Create a customer with an order', async () => {
    const response = await request.post('/test-api/customers-with-orders', {
      data: { item: 'QA Notebook' },
    });
    expect(response.ok()).toBeTruthy();
    const body = await response.json() as { customerId: string };
    return body.customerId;
  });

  await test.step('Change the delivery address', async () => {
    await page.goto(`/customers/${customerId}/orders/latest`);
    await page.getByRole('button', { name: 'Edit delivery address' }).click();
    await page.getByLabel('Street').fill('12 Quality Lane');
    await page.getByLabel('City').fill('Pune');
    await page.getByRole('button', { name: 'Save address' }).click();
  });

  await test.step('Verify the saved address', async () => {
    await expect(page.getByRole('status')).toHaveText('Address updated');
    await expect(page.getByTestId('delivery-address'))
      .toContainText('12 Quality Lane, Pune');
  });
});

The API setup returns data through the step instead of mutating an outer let. The act step contains user interaction, while the final step owns the observable acceptance criteria. A failure report immediately identifies whether data provisioning, UI interaction, or verification failed.

Do not force every test into three steps. A focused accessibility or locator test may need one named operation. The pattern is valuable when phases have different owners or evidence.

2. Playwright test.step Examples That Return Typed Data

Returning values is especially clean for helper functions. The promise returned by test.step() resolves to the callback's result, so TypeScript can carry a model into later steps.

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

type Invoice = {
  id: string;
  number: string;
  total: number;
};

async function createInvoice(request: APIRequestContext): Promise<Invoice> {
  return test.step('Create a draft invoice', async () => {
    const response = await request.post('/test-api/invoices', {
      data: { quantity: 2, unitPrice: 25 },
    });
    expect(response.ok()).toBeTruthy();
    return await response.json() as Invoice;
  }, { box: true });
}

test('draft invoice total is displayed', async ({ request, page }) => {
  const invoice = await createInvoice(request);

  await test.step(`Open invoice ${invoice.number}`, async () => {
    await page.goto(`/invoices/${invoice.id}`);
  });

  await test.step('Verify invoice total', async () => {
    await expect(page.getByTestId('invoice-total')).toHaveText('$50.00');
    expect(invoice.total).toBe(50);
  });
});

Dynamic titles can help when the identifier is safe and bounded. Invoice numbers in a test environment may be acceptable, while email addresses, access tokens, and full payloads are not. High-cardinality titles can also make report aggregation difficult. Prefer a stable title and attach details when metrics group by exact step name.

Return plain data or durable clients with a clear lifecycle. Do not return an element handle from a page that the step closes. Locators are lazy, but their page must still exist when used.

3. Example: Reusable Step Helpers With box and timeout

A small wrapper can standardize domain helper behavior without hiding Playwright's API. Keep the wrapper thin and return the callback value.

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

export async function businessStep<T>(
  title: string,
  body: () => Promise<T>,
): Promise<T> {
  return test.step(title, body, {
    box: true,
    timeout: 15_000,
  });
}
import { expect, type Page } from '@playwright/test';
import { businessStep } from './business-step';

export async function inviteMember(page: Page, email: string) {
  return businessStep('Invite a workspace member', async () => {
    await page.getByRole('button', { name: 'Invite member' }).click();
    await page.getByLabel('Work email').fill(email);
    await page.getByRole('button', { name: 'Send invitation' }).click();
    await expect(page.getByRole('status')).toHaveText('Invitation sent');
  });
}

The wrapper makes all business steps boxed and gives each a 15-second ceiling. That consistency is useful only if 15 seconds is a valid contract for every wrapped operation. A single global step wrapper is a bad fit when report call sites and performance characteristics differ widely.

Consider exposing separate helpers such as businessStep() and direct test.step() rather than adding many wrapper options. Engineers should still recognize standard Playwright concepts in code. If a failure is hard to debug, remove boxing temporarily or inspect Trace Viewer for the internal action.

4. Example: Steps Inside a Page Object

Page objects should expose user or domain operations. Wrapping those methods in steps prevents test files from duplicating report structure.

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

export class SubscriptionPage {
  constructor(private readonly page: Page) {}

  async open() {
    await test.step('Open subscription settings', async () => {
      await this.page.goto('/settings/subscription');
      await expect(this.page.getByRole('heading', { name: 'Subscription' }))
        .toBeVisible();
    }, { box: true });
  }

  async selectPlan(planName: string) {
    await test.step(`Select the ${planName} plan`, async () => {
      const plan = this.page.getByRole('group', { name: planName });
      await plan.getByRole('button', { name: 'Select plan' }).click();
      await expect(plan).toHaveAttribute('data-selected', 'true');
    }, { box: true });
  }

  async confirmChange() {
    await test.step('Confirm subscription change', async () => {
      await this.page.getByRole('button', { name: 'Confirm change' }).click();
      await expect(this.page.getByRole('status')).toHaveText('Plan updated');
    }, { box: true });
  }
}
import { test } from '@playwright/test';
import { SubscriptionPage } from './subscription-page';

test('customer changes to the Team plan', async ({ page }) => {
  const subscription = new SubscriptionPage(page);
  await subscription.open();
  await subscription.selectPlan('Team');
  await subscription.confirmChange();
});

The test now reads like a scenario, and the report uses the same domain operations. Do not add another outer step around all three calls unless that parent adds a useful phase. Otherwise the report gains an empty level of hierarchy.

Keep assertions that prove each method's postcondition inside the method. The final test can still assert cross-operation business outcomes.

5. Example: TypeScript Decorator for Automatic Page Object Steps

For a repository that already uses modern TypeScript decorators, a decorator can wrap page object methods automatically. This pattern follows the documented ClassMethodDecoratorContext approach.

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

export function boxedStep(
  target: Function,
  context: ClassMethodDecoratorContext,
) {
  return function replacementMethod(this: object, ...args: unknown[]) {
    const name = `${this.constructor.name}.${String(context.name)}`;
    return test.step(name, async () => {
      return await target.call(this, ...args);
    }, { box: true });
  };
}
import { expect, type Page } from '@playwright/test';
import { boxedStep } from './boxed-step';

export class LoginPage {
  constructor(private readonly page: Page) {}

  @boxedStep
  async login(email: string, password: string) {
    await this.page.goto('/login');
    await this.page.getByLabel('Email').fill(email);
    await this.page.getByLabel('Password').fill(password);
    await this.page.getByRole('button', { name: 'Sign in' }).click();
    await expect(this.page.getByRole('banner')).toContainText(email);
  }
}

The report title is stable and based on the class and method, not the arguments. That avoids leaking the password or user identity. The replacement returns the test.step() promise, preserving the decorated method's asynchronous result.

Decorators add compiler and debugging complexity. Direct wrappers are preferable in a project that does not already enable and understand the relevant TypeScript decorator model. Do not introduce the pattern only to save three lines in a small page object.

6. Example: Attach API and Visual Evidence to One Step

TestStepInfo.attach() makes evidence discoverable under the operation that produced it. The attachment accepts a body or a path, not both.

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

test('order summary matches the order API', async ({ page, request }) => {
  await page.goto('/orders/qa-order-88');

  await test.step('Compare the rendered order summary', async step => {
    const response = await request.get('/api/orders/qa-order-88');
    expect(response.ok()).toBeTruthy();

    const order = await response.json() as {
      id: string;
      state: string;
      itemCount: number;
    };

    await step.attach('sanitized-order', {
      body: JSON.stringify(order, null, 2),
      contentType: 'application/json',
    });

    const screenshot = await page.getByTestId('order-summary').screenshot();
    await step.attach('rendered-order-summary', {
      body: screenshot,
      contentType: 'image/png',
    });

    await expect(page.getByTestId('order-state')).toHaveText(order.state);
    await expect(page.getByTestId('item-count'))
      .toHaveText(String(order.itemCount));
  });
});

The API type intentionally contains only fields needed by the assertion. In a real project, sanitize server responses before attaching them. Reports may be downloadable by a broader audience than runtime secrets.

Locator screenshots are often more focused than full-page images. Global trace and screenshot configuration should handle general failure evidence. Attach extra artifacts only when their relationship to a step materially improves triage.

7. Example: Conditionally Skip an Independent Step

Use the TestStepInfo callback parameter when a verification is not applicable to a project configuration but the rest of the test remains valid.

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

test('product page exposes available sharing controls', async ({
  page,
  browserName,
}) => {
  await page.goto('/products/qa-camera');

  await test.step('Verify native share control', async step => {
    step.skip(
      browserName === 'firefox',
      'native share integration is not enabled in the Firefox project',
    );
    await expect(page.getByRole('button', { name: 'Share product' }))
      .toBeVisible();
  });

  await test.step('Verify copy-link control', async () => {
    await expect(page.getByRole('button', { name: 'Copy link' })).toBeVisible();
  });
});

When the condition is true, the current step stops and is marked skipped. The next step executes. This structure is valid because copy-link verification does not depend on the native share step.

If a complete scenario is not applicable to Firefox, use test.skip() for the test or a conditional annotation at a broader scope. Splitting browser-specific expectations into separate tests can also produce clearer coverage. Never call step.skip() after catching an unexpected failure.

Track skip descriptions as test debt. A platform difference is a durable reason, while "flaky this week" should carry an issue and remediation plan.

8. Example: Combine Steps With Soft Assertions Carefully

Soft assertions record failures and let the test continue. Steps still complete unless code explicitly checks accumulated errors. This can be useful when one verification phase should collect several related mismatches.

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

test('profile summary shows persisted fields', async ({ page }) => {
  await page.goto('/profile');

  await test.step('Verify identity fields', async () => {
    await expect.soft(page.getByTestId('display-name')).toHaveText('Avery QA');
    await expect.soft(page.getByTestId('role')).toHaveText('Quality Engineer');
  });

  await test.step('Verify regional fields', async () => {
    await expect.soft(page.getByTestId('time-zone')).toHaveText('Asia/Kolkata');
    await expect.soft(page.getByTestId('language')).toHaveText('English');
  });
});

Playwright marks the test failed at the end if soft assertions failed, even though later steps ran. The report can show multiple discrepancies in one execution. Use this only when continuing is safe and useful. If the first mismatch invalidates later actions, use a normal assertion to stop.

Do not wrap normal assertions in try/catch to imitate soft behavior. expect.soft() communicates intent and integrates with the runner. Avoid dozens of soft failures that obscure the primary defect. Group related read-only verification, and keep state-changing steps protected by hard preconditions.

9. Example: Bound a Slow Phase Without Hiding Its Cause

A step-level timeout is valuable for operations that have a known business bound. Combine it with web-first assertions so the report shows both the phase and the failed condition.

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

test('generated report becomes available', async ({ page }) => {
  await page.goto('/reports/new');

  await test.step('Request the quality report', async () => {
    await page.getByLabel('Report type').selectOption('quality-summary');
    await page.getByRole('button', { name: 'Generate report' }).click();
    await expect(page.getByRole('status')).toHaveText('Report queued');
  }, { timeout: 8_000 });

  await test.step('Wait for the download action', async () => {
    await expect(page.getByRole('link', { name: 'Download report' }))
      .toBeVisible({ timeout: 25_000 });
  }, { timeout: 30_000 });
});

The second step gives the assertion 25 seconds and the phase 30 seconds. The small difference leaves time for callback overhead, although exact timing is environment-dependent. Both remain inside the test's overall timeout, which may need to be configured appropriately for a legitimate 30-second operation.

Never add a test retry because a deterministic operation needs a documented longer wait. Use the correct timeout layer and investigate unexpected slowness. The Playwright test retry annotation examples guide explains when full reruns are justified.

10. Example: Use Step Titles for Useful Reporting

Stable titles make HTML reports easier to scan and custom results easier to aggregate. Prefer a verb, a domain object, and an expected transition. Examples include "Create trial workspace," "Apply expired coupon," and "Verify payment rejection."

Avoid titles that contain full parameters:

// Avoid exposing input and creating a unique title for every user.
await test.step(`Login with ${email} and ${password}`, async () => {
  // ...
});

// Prefer a stable, safe title.
await test.step('Sign in as the test member', async step => {
  await step.attach('account-alias', {
    body: 'member-a',
    contentType: 'text/plain',
  });
  // ...
});

Do not attach even an alias unless it helps investigation. The example shows the safer location for bounded metadata, not a requirement to add it.

The reporter model exposes step title, category, duration, error, child steps, and attachments. Use those fields to find repeated failing phases or unusually slow setup. Do not treat browser-step duration as a formal product performance measurement because shared CI load, tracing, retries, and worker scheduling affect it.

Create a small naming vocabulary for common domains. For example, consistently use "Create," "Submit," "Approve," and "Verify" rather than mixing vague verbs such as "Handle" and "Process." Consistency helps search, but titles still need enough context to distinguish an order approval from a refund approval. Do not encode expected status as an unexplained number when a human-readable business state is available.

If a custom reporter consumes step titles, treat those titles as a lightweight contract. Change the reporter to use explicit metadata if exact wording becomes too rigid. Test source should remain readable and free to improve. A fragile analytics parser that depends on punctuation can block useful title edits and encourage awkward language.

When error location matters, compare boxed and unboxed reports. A boxed page object method can make the test call site clear, while an unboxed scenario step can point directly to the failed assertion. Choose based on the consumer of the report.

11. Best Practices for Playwright test.step Examples

Apply these standards across a mature suite:

  1. Name behavior, not implementation. "Submit refund" is better than "click submit."
  2. Await every step and every asynchronous assertion or attachment inside it.
  3. Limit a typical scenario to a handful of top-level steps.
  4. Return values from steps instead of mutating outer variables.
  5. Put page object steps around public domain methods, not locators.
  6. Use stable titles and keep credentials, personal data, and large inputs out of them.
  7. Box stable helpers only when the call site is the better ownership boundary.
  8. Set step timeouts from real operation contracts, not to mask a slow suite.
  9. Attach focused, sanitized evidence and rely on traces for broad diagnostics.
  10. Skip only independent optional work, with a precise reason.
  11. Use soft assertions only when collecting multiple failures is safe.
  12. Force a representative failure and inspect the report before standardizing a pattern.

Steps do not replace fixtures, hooks, or independent test design. Use Playwright fixture override examples for managed setup and teardown. If locator ambiguity is the failure source, fix it using the Playwright strict mode violation guide instead of adding more reporting structure.

Establish the convention with examples from the real application rather than a blanket lint rule. One team may use steps primarily in long browser journeys, while another also uses them for API workflows and test-data preparation. Both can be correct if reports remain concise and failures stay actionable. Revisit the convention after framework upgrades because new reporter and trace capabilities can reduce the need for custom wrappers.

Finally, separate report readability from test correctness in review. A beautifully narrated test can still share mutable state, use an ambiguous locator, or miss an assertion. Evaluate isolation, synchronization, and acceptance criteria first, then use steps to expose that sound design clearly.

Interview Questions and Answers

Q: What makes a good test.step() title?

It starts with a verb and describes a domain operation or expected transition. It stays stable across runs and avoids secrets or large parameter values. The title should add meaning beyond the low-level actions already shown by Playwright.

Q: How do you pass data from one step to another?

Return the data from the first step callback and await test.step() into a typed variable. This is cleaner than assigning through an outer mutable variable. The returned resource must remain valid after the step finishes.

Q: When would you use a boxed step?

Use it for a stable reusable helper or page object operation when the caller is the actionable ownership boundary. An internal error still fails the test, but the reported source location points to the step call. Leave it unboxed when the exact internal line is more useful.

Q: Can you use test.step() in a page object?

Yes. High-level page object methods are strong step boundaries because they represent user operations. Avoid wrapping locator properties and trivial getters, which creates noisy nested reports.

Q: How do step attachments differ from automatic screenshots?

A step attachment is explicitly tied to one logical operation and can contain a body or file. Automatic screenshot and trace policies apply broadly according to configuration. Use explicit attachments for focused domain evidence and global policies for general diagnostics.

Q: What happens when step.skip(true, reason) runs?

The current step stops and is marked skipped. Later test code can continue. It is safe only when subsequent work does not depend on the skipped operation.

Q: Can soft assertions make a step pass when checks fail?

The callback continues after a soft assertion failure, so the step may complete and later steps may run. Playwright still fails the test because soft errors were recorded. Use the pattern only for independent read-only checks.

Common Mistakes

  • Creating a step for every click, fill, and assertion, which duplicates built-in action reporting.
  • Forgetting to await a helper that returns test.step().
  • Adding user emails, passwords, tokens, or full JSON inputs to step titles.
  • Returning a resource whose page, context, or fixture has already been disposed.
  • Nesting page object steps under redundant outer steps and making reports hard to scan.
  • Standardizing one boxed, timed wrapper for operations with very different needs.
  • Attaching complete API responses without sanitizing protected fields.
  • Using step.skip() after an assertion failure to make the remaining report look successful.
  • Continuing state-changing actions after a soft prerequisite assertion failed.
  • Increasing step and test timeouts without identifying the slow boundary.
  • Treating step timings from shared CI as formal application performance results.

Conclusion

The best Playwright test.step examples create a compact business narrative while leaving technical evidence available underneath. Return typed values, wrap stable page object operations, select boxing based on error ownership, set timeouts at real phase boundaries, and attach only focused evidence.

Choose one representative end-to-end test, apply three to five meaningful steps, and inspect both a passing and deliberately failed report. If the reader can identify the failed business phase immediately and still reach the exact action, the step design is doing its job.

Interview Questions and Answers

How would you structure a long checkout test with steps?

I would use a small number of business phases such as provision the cart, submit checkout, and verify the order. Each step would contain the actions and assertions relevant to that phase. I would avoid wrapping every locator call because Playwright already reports those actions.

Why return data from a step instead of assigning to an outer variable?

Returning creates explicit asynchronous data flow and preserves TypeScript inference. The caller cannot accidentally use the value before the step completes. It also keeps setup code self-contained in the report.

What methods belong in page object steps?

Public domain operations such as login, submit address, or confirm subscription are strong candidates. Locator getters and one-line wrappers are not. The report should reflect user intent rather than the implementation surface.

What tradeoff does a step decorator introduce?

It reduces repeated wrappers and creates consistent titles, but adds compiler configuration and indirection. Debugging replacement methods may be less familiar to the team. I use it only when the repository already treats decorators as a supported convention.

How do you decide what to attach to a step?

I attach evidence that is specific to the operation and materially shortens triage, such as a sanitized response subset or locator screenshot. I avoid duplicate global artifacts, secrets, customer data, and very large bodies. Every attachment call is awaited.

When are soft assertions appropriate inside steps?

They are appropriate for collecting several independent display mismatches in one verification phase. They are unsafe when the first check is a prerequisite for later state changes. The test still fails even though later steps can execute.

How would you review step quality?

I force a representative failure and inspect the HTML report and trace. The failed business phase should be obvious, titles should be stable and safe, nesting should be shallow, and the exact technical cause should remain reachable. Source elegance alone is not enough.

Frequently Asked Questions

What is a good Playwright test.step example structure?

For a long end-to-end test, use a few steps such as create data, perform the user action, and verify the outcome. Name each with a verb and domain object rather than Arrange, Act, or Assert alone.

How do I use test.step in a Playwright page object?

Wrap a public domain method in await test.step() and keep its actions and postcondition assertion inside. Avoid adding steps to locator properties or trivial getters.

Can a TypeScript decorator create Playwright steps?

Yes. A modern method decorator can return a replacement that calls test.step() and invokes the original method. Use it only when the project already has compatible compiler settings and a clear decorator standard.

Should step titles include test data?

Only include bounded, non-sensitive data when it materially helps diagnosis. Stable titles aggregate better, and focused attachments are safer for variable details.

Can test.step contain soft assertions?

Yes. Soft assertion failures are recorded and later code continues, while the test still fails at completion. Use them for independent read-only checks, not failed prerequisites for state-changing work.

How many test steps should a test have?

There is no fixed limit, but a handful of top-level business steps is usually readable. Inspect the generated report because Playwright also shows nested actions, assertions, hooks, and fixtures.

Can a helper standardize Playwright step options?

Yes. A thin generic helper can call test.step() with shared box or timeout options and return the callback value. Use it only when those options reflect the same contract for every wrapped operation.

Related Guides