microsoft/playwright · error · Error

test.step() can only be called from a test

Error message

test.step() can only be called from a test

What it means

test.step() (and step.skip()) internally call _step, which requires an active test: it reads currentTestInfo() from the runner's async context. When no test is currently executing, the returned testInfo is undefined and the error is thrown.

Source

Thrown at packages/playwright/src/common/testType.ts:280

    }

    const testInfo = currentTestInfo();
    if (!testInfo)
      throw new Error(`test.setTimeout() can only be called from a test`);
    testInfo.setTimeout(timeout);
  }

  private _use(location: Location, fixtures: Fixtures) {
    const suite = this._currentSuite(location, `test.use()`);
    if (!suite)
      return;
    suite._use.push({ fixtures, location });
  }

  async _step<T>(expectation: 'pass'|'skip', title: string, body: (step: TestStepInfo) => T | Promise<T>, options: {box?: boolean, location?: Location, timeout?: number, params?: Record<string, any>, subtitle?: string } = {}): Promise<T> {
    const testInfo = currentTestInfo();
    if (!testInfo)
      throw new Error(`test.step() can only be called from a test`);
    await testInfo._onUserStepBegin?.(title);
    const step = testInfo._addStep({ category: 'test.step', title, subtitle: options.subtitle, location: options.location, box: options.box, params: options.params });
    return await currentZone().with('stepZone', step).run(async () => {
      try {
        let result: Awaited<ReturnType<typeof raceAgainstDeadline<T>>> | undefined = undefined;
        result = await raceAgainstDeadline(async () => {
          try {
            return await step.info._runStepBody(expectation === 'skip', body, step.location);
          } catch (e) {
            // If the step timed out, the test fixtures will tear down, which in turn
            // will abort unfinished actions in the step body. Record such errors here.
            if (result?.timedOut)
              testInfo._failWithError(e);
            throw e;
          }
        }, options.timeout ? monotonicTime() + options.timeout : 0);
        if (result.timedOut)
          throw new TimeoutError(`Step timeout of ${options.timeout}ms exceeded.`);

View on GitHub (pinned to 312030cdce)

Solutions

  1. Move the test.step() call inside test() or beforeEach/afterEach (test-scoped hooks)
  2. For worker-scoped setup (beforeAll), use plain logging or annotate the test instead, or restructure setup into a beforeEach
  3. If called from a shared helper, pass and check currentTestInfo or accept a fallback path when not inside a test

Example fix

// before
test.beforeAll(async () => {
  await test.step('setup database', async () => { ... }); // throws
});

// after
test.beforeEach(async () => {
  await test.step('setup database', async () => { ... });
});
Defensive patterns

Strategy: type-guard

Validate before calling

import { test } from '@playwright/test';
// Only inside test-scoped hooks; before calling from helpers:
const insideTest = !!((await import('@playwright/test'))._baseTest as any)?.currentTestInfo?.();

Type guard

import { test } from '@playwright/test';
async function safeStep(title: string, body: () => Promise<void>) {
  try {
    await test.step(title, body);
  } catch (e) {
    if (!(e as Error).message.includes('can only be called from a test')) throw e;
    console.log(`(outside test) ${title}`);
    await body();
  }
}

Try / catch

try { await test.step('x', fn); }
catch (e) { if ((e as Error).message.includes('can only be called from a test')) { /* fall back to direct call */ await fn(); } else throw e; }

Prevention

When it happens

Trigger: Calling test.step() outside a running test: at module/collector top level, inside beforeAll/afterAll (worker-scoped hooks have no current test), in a spawned worker/child process, or in a dynamically imported module evaluated outside the test zone.

Common situations: Moving helper code that uses test.step into beforeAll; calling test.step from a utility invoked during test collection or from a standalone script that imports the test object; using test.step inside a task/promise that escapes the test's async context after the test finished.

Related errors


AI-assisted analysis of microsoft/playwright@312030cdce (2026-09-07). Data as JSON: /api/errors/48e831750b6440cc. Report an issue: GitHub.