Skip to content
Yevhen Laichenkov
Go back

TIL: Playwright step decorator for better test reporting

Suggest an edit

If you use page objects in Playwright, your trace reports can quickly become a wall of low-level actions. You can fix this by wrapping methods with test.step(), but doing it manually everywhere is tedious.

Here’s a @step() decorator that does it automatically:

Full implementation
import { test } from "@playwright/test";

type Method<This, Args extends unknown[], Return> = (
  this: This,
  ...args: Args
) => Promise<Return>;

type MethodDecoratorContext<
  This,
  Args extends unknown[],
  Return,
> = ClassMethodDecoratorContext<This, Method<This, Args, Return>>;

function extractParams(fn: Function): string[] {
  const fnStr = fn.toString();
  const argsMatch = fnStr.match(/\(([^)]*)\)/);

  if (!argsMatch?.[1]) return [];

  return argsMatch[1]
    .split(",")
    .map(param => param.trim())
    .filter(Boolean)
    .map(param => param.replace(/=.*$/, "").trim())
    .map(param => param.replace(/^\.\.\./, "").trim());
}

function interpolateParams<Args extends unknown[]>(
  message: string,
  fn: Function,
  args: Args
): string {
  const paramNames = extractParams(fn);

  return message.replace(/\{\{(\w+)\}\}/g, (_, paramName) => {
    const index = paramNames.indexOf(paramName);
    if (index === -1 || index >= args.length) return `{{${paramName}}}`;

    const value = args[index];
    return typeof value === "object" ? JSON.stringify(value) : String(value);
  });
}

export function step<
  This extends { constructor: { name: string } },
  Args extends unknown[],
  Return,
>(message?: string) {
  return (
    value: Method<This, Args, Return>,
    context: MethodDecoratorContext<This, Args, Return>
  ) => {
    const target = value;
    const name = context.name ?? "unknown";

    function replacementMethod(
      this: This,
      ...args: Args
    ): Promise<Return> {
      const defaultName = `${this.constructor.name}.${String(name)}`;
      const stepName = message
        ? interpolateParams(message, target, args)
        : defaultName;

      return test.step(stepName, async () => {
        return await target.call(this, ...args);
      });
    }

    return replacementMethod as Method<This, Args, Return>;
  };
}

Now every method decorated with @step() shows up as a named step in Playwright traces and reports:

class LoginPage {
  @step("Log in with {{username}}")
  async logIn(username: string, password: string) {
    await this.page.getByLabel("Username").fill(username);
    await this.page.getByLabel("Password").fill(password);
    await this.page.getByRole("button", { name: "Sign in" }).click();
  }

  @step() // defaults to ClassName.methodName (e.g. "LoginPage.navigateTo")
  async navigateTo() {
    await this.page.goto("/login");
  }
}

The {{paramName}} syntax uses the parameter names from the function signature, so {{username}} resolves to the actual value passed at runtime. This makes trace reports way more readable — instead of seeing a generic step name, you see "Log in with john.doe".


Suggest an edit
Share this post on:

Next Post
TIL: git worktree lets you work on multiple branches at once