|

Custom Matchers in Playwright with expect.extend

Every Playwright suite eventually grows a pile of repeated assertions: checking that an element has a specific data attribute, that an API response carries a valid JWT, or that a price string is formatted correctly. Copy-pasting expect(...).toBe(...) chains everywhere makes tests noisy and failure messages cryptic. In this guide you will learn how Playwright custom matchers with expect.extend let you wrap that logic into named, reusable, auto-retrying assertions that read like plain English and produce clear diagnostics when they fail.

🎭 Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.

Contents

What Is expect.extend in Playwright?

Playwright’s expect ships with a rich set of built-in matchers like toBeVisible(), toHaveText(), and toHaveURL(). But your application has domain-specific rules that Playwright cannot know about. expect.extend is the official API for teaching expect new matchers. You pass it an object whose keys are matcher names and whose values are functions that return a result object describing whether the assertion passed.

Playwright builds its expect on top of the same matcher contract popularized by Jest, so the function signature will feel familiar. Each matcher receives the received value as its first argument, followed by any arguments the caller passed. It must return an object with a boolean pass and a message function that explains the result for both the positive and negated (.not) cases.

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

expect.extend({
  toBeWithinRange(received: number, floor: number, ceiling: number) {
    const pass = received >= floor && received <= ceiling;
    return {
      pass,
      message: () =>
        `expected ${received} ${pass ? 'not ' : ''}to be within range ${floor} - ${ceiling}`,
      name: 'toBeWithinRange',
      expected: `${floor} - ${ceiling}`,
      actual: received,
    };
  },
});

// Now usable anywhere expect is imported from this module:
// expect(7).toBeWithinRange(1, 10);
// expect(20).not.toBeWithinRange(1, 10);

That is the entire mechanism. The pass flag drives the result, and Playwright automatically inverts it when you chain .not. The message function is only invoked when the assertion fails, so you can build a detailed string without paying for it on the happy path.

Why Custom Matchers Beat Helper Functions

You could write a plain helper like function assertInRange(n, lo, hi), so why bother with expect.extend? Custom matchers integrate with Playwright’s reporter, trace viewer, and step output. A failure shows up as a real expectation with a labelled actual-versus-expected diff, not a generic thrown error buried in a stack trace.

  • Readable call sitesexpect(order.total).toBeWithinRange(10, 50) states intent better than a bare function call.
  • Negation for free — every matcher automatically supports .not with no extra code.
  • Better failure messages — the message() function and actual/expected fields feed Playwright’s HTML report.
  • Composable with soft assertions — your matcher works with expect.soft and expect.poll just like a built-in.
ApproachReporter integrationSupports .notAuto-retryReusability
Plain helper functionNoNoNoManual import
Inline expect chainPartialBuilt-ins onlyBuilt-ins onlyCopy-paste
Custom matcher (expect.extend)YesYesYes (async polling)One shared module

Building a Locator-Aware Custom Matcher

The most useful matchers operate on Playwright Locator objects and follow the auto-retrying behaviour of built-ins like toBeVisible(). To get retries, your matcher’s body must await a Locator method that itself retries, or use expect.poll/web-first assertions internally. Here is a matcher that asserts an element carries a specific data-state attribute, polling until it does or the timeout expires.

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

expect.extend({
  async toHaveDataState(locator: Locator, expected: string, options?: { timeout?: number }) {
    let actual: string | null = null;
    let pass = false;
    try {
      // expect.poll retries the callback until it returns the awaited value.
      await expect
        .poll(async () => {
          actual = await locator.getAttribute('data-state');
          return actual;
        }, { timeout: options?.timeout ?? 5000 })
        .toBe(expected);
      pass = true;
    } catch {
      pass = false;
    }
    return {
      pass,
      name: 'toHaveDataState',
      expected,
      actual,
      message: () =>
        `expected locator to ${pass ? 'not ' : ''}have data-state="${expected}", ` +
        `but last saw "${actual}"`,
    };
  },
});

// Usage inside a test:
// await expect(page.getByTestId('toggle')).toHaveDataState('open');

By delegating the waiting to expect.poll, the matcher inherits Playwright’s retry loop. The element does not need to be in the right state immediately; the matcher keeps re-reading the attribute until the timeout. This is the single biggest reason to write locator matchers with expect.extend instead of a one-shot helper.

Adding TypeScript Declarations So expect Is Type-Safe

Out of the box, TypeScript does not know your new matchers exist, so calling expect(x).toBeWithinRange(1, 10) raises a compile error. You fix this with a declaration-merging block that augments Playwright’s Matchers interface. Place it in a .d.ts file or at the top of your matcher module.

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

// Augment Playwright's Matchers interface. R is the receiver type.
declare module '@playwright/test' {
  interface Matchers<R, T = unknown> {
    toBeWithinRange(floor: number, ceiling: number): R;
    toHaveDataState(expected: string, options?: { timeout?: number }): Promise<R>;
  }
}

expect.extend({
  toBeWithinRange(received: number, floor: number, ceiling: number) {
    const pass = received >= floor && received <= ceiling;
    return {
      pass,
      name: 'toBeWithinRange',
      message: () => `expected ${received} ${pass ? 'not ' : ''}to be within ${floor}-${ceiling}`,
    };
  },
  async toHaveDataState(locator: Locator, expected: string, options?: { timeout?: number }) {
    let actual: string | null = null;
    let pass = false;
    try {
      await expect
        .poll(async () => (actual = await locator.getAttribute('data-state')), {
          timeout: options?.timeout ?? 5000,
        })
        .toBe(expected);
      pass = true;
    } catch {
      pass = false;
    }
    return {
      pass,
      name: 'toHaveDataState',
      actual,
      expected,
      message: () => `expected data-state "${expected}", saw "${actual}"`,
    };
  },
});

A few things matter here. The generic parameters <R, T> must match Playwright’s own signature, where R is the return type the chain expects. Async matchers return Promise<R> so that await expect(...).toHaveDataState(...) type-checks. Make sure tsconfig.json includes your declaration file, and import this module once so the side-effecting expect.extend call actually runs.

Registering Matchers Globally Across the Suite

Use a setup file imported everywhere

Calling expect.extend only registers matchers in the module where it runs. To make them available in every spec, put the call in a single file and load it before tests. The cleanest way is a custom fixture file that re-exports test and expect, so importing from it pulls in the matchers as a side effect.

// fixtures.ts — import this instead of '@playwright/test' in your specs
import { test as base, expect } from '@playwright/test';
import './matchers'; // runs expect.extend once for the whole process

export const test = base;
export { expect };

// some.spec.ts
import { test, expect } from './fixtures';

test('order total falls in the expected band', async ({ page }) => {
  await page.goto('/checkout');
  const totalText = await page.getByTestId('grand-total').innerText();
  const total = Number(totalText.replace(/[^0-9.]/g, ''));
  expect(total).toBeWithinRange(10, 500);
  await expect(page.getByTestId('cart-drawer')).toHaveDataState('open');
});

Alternatively, add the matcher file to globalSetup or list it as the first import in a shared base test. The key idea is that expect.extend mutates the shared expect object, so it only needs to execute once per worker process. Importing the side-effecting module from your fixtures file guarantees that.

🚀 Level Up Your Playwright

From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.

A Practical Example: Validating an API Response Shape

Custom matchers are not limited to UI. When you test APIs with Playwright’s request fixture, a matcher that validates a response body keeps your tests declarative. Below, toBeSuccessfulJson checks the status code and that the parsed body contains the expected keys, producing a precise message about what was missing.

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

declare module '@playwright/test' {
  interface Matchers<R, T = unknown> {
    toBeSuccessfulJson(requiredKeys: string[]): Promise<R>;
  }
}

expect.extend({
  async toBeSuccessfulJson(response: APIResponse, requiredKeys: string[]) {
    const status = response.status();
    let body: Record<string, unknown> = {};
    let missing: string[] = [];
    try {
      body = await response.json();
      missing = requiredKeys.filter((k) => !(k in body));
    } catch {
      return {
        pass: false,
        name: 'toBeSuccessfulJson',
        message: () => `expected JSON body but response was not parseable (status ${status})`,
      };
    }
    const pass = status >= 200 && status < 300 && missing.length === 0;
    return {
      pass,
      name: 'toBeSuccessfulJson',
      message: () =>
        pass
          ? `expected response NOT to be successful JSON, but it was (status ${status})`
          : `expected 2xx with keys [${requiredKeys.join(', ')}]; ` +
            `got status ${status}, missing [${missing.join(', ')}]`,
    };
  },
});

// Usage:
// const res = await request.get('/api/user/42');
// await expect(res).toBeSuccessfulJson(['id', 'email', 'createdAt']);

This matcher reads the body once and reports exactly which keys were absent, which is far more actionable than a generic expect(res.ok()).toBeTruthy() failure. Because it returns a promise, callers must await it, and the TypeScript declaration enforces that at compile time.

Tips, Pitfalls, and Best Practices

  • Always handle the negated case in message(). Check pass to decide whether to add the word “not”, otherwise .not failures read backwards.
  • Do not throw inside a matcher for normal failures — return pass: false. Throwing should be reserved for genuinely invalid usage, such as passing a non-Locator.
  • Make locator matchers retry by delegating to expect.poll or to an existing web-first assertion; a single getAttribute read will be flaky.
  • Keep matcher modules side-effect-only for the expect.extend call and import them once via fixtures so every worker registers them.
  • Set actual and expected on the result object to get a clean diff in the HTML report and trace viewer.

Used well, Playwright custom matchers with expect.extend turn brittle, repetitive assertions into a small library of domain-specific expectations that are type-safe, self-documenting, and integrated with Playwright’s reporting. Start by extracting your two or three most-repeated assertion patterns into matchers, register them through a shared fixtures file, and your specs immediately become shorter and your failure messages dramatically clearer.

FAQ

Do Playwright custom matchers support auto-retrying like built-in web-first assertions?

Not automatically. A matcher only retries if its body awaits something that retries. Wrap your read in expect.poll or delegate to an existing web-first assertion inside the matcher, and it will keep re-evaluating until the condition is met or the timeout expires, just like toBeVisible().

Why does TypeScript complain that my custom matcher does not exist?

TypeScript does not know about runtime-registered matchers until you augment the Matchers interface. Add a declare module '@playwright/test' block that extends interface Matchers<R, T> with your matcher signatures, return Promise<R> for async matchers, and ensure the declaration file is included by your tsconfig.json.

Where should I call expect.extend so matchers work in every test?

Call it once in a dedicated matcher module, then import that module from a shared fixtures file that re-exports test and expect. Have your specs import from the fixtures file instead of directly from @playwright/test. Because expect.extend mutates the shared expect, running it once per worker process is enough.

🎓 Master Playwright End to End

Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.