Playwright architecture, selector reliability, and advanced interaction patterns.

Writing Custom expect Matchers

Every suite eventually grows an assertion that the built-in matchers cannot express in one line: a price cell that must equal a decimal after currency formatting, a chart legend that must contain exactly the series the API returned, a row that must be flagged as reconciled. Teams usually solve this with a helper function that reads the DOM and then calls expect() on a plain value — and that helper silently throws away the retry loop that makes Web-First Assertions reliable. expect.extend() is the supported way to add a domain matcher that keeps retrying, keeps its own timeout budget, and prints a real diff when it fails. This page shows the full matcher contract, the polling behaviour you must preserve, and how to wire several matcher modules into a single typed expect.

Lifecycle of a retrying custom matcher A call site enters the matcher body, which reads the locator, compares against the expected value, and either loops until the timeout expires or returns a result with a formatted message. polling window — this.timeout expect(card) .toHaveAmount(5) matcher body reads locator compare vs expected compute pass pass === true? or time remaining retry yes no return pass message() diff the call site awaits one promise for the whole loop
A custom matcher is one awaited promise at the call site, but internally it must keep re-reading the page until the condition holds or the timeout budget runs out.

Root cause: a matcher that reads once is a snapshot, not an assertion

Playwright's locator matchers retry because the runner re-evaluates the whole condition on an interval until it holds or the expect timeout expires. A hand-rolled matcher that calls await locator.innerText() and compares the result performs exactly one read, so it asserts on whatever the DOM contained during that single microtask — before hydration finishes, before the fetch resolves, before the animation settles. The matcher is not slow or wrong on the happy path; it is nondeterministic, which is the mechanism behind most of the failures described in Detecting and Fixing Flaky Playwright Tests. The fix is not to add a sleep, but to build the matcher so that the retry loop belongs to it.

Minimal reproducible example

The matcher below is the naive version almost everyone writes first. It extends expect, so the call site reads well, but the body takes a single snapshot of the DOM.

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

// FLAKY: this matcher reads the DOM exactly once and decides immediately.
export const expect = baseExpect.extend({
  async toHaveAmount(locator: Locator, expected: number) {
    const raw = await locator.innerText();              // one read, no retry
    const actual = Number(raw.replace(/[^0-9.-]/g, '')); // strip "$" and "," etc.
    return {
      pass: actual === expected,
      message: () => `expected ${expected}, received ${actual}`,
    };
  },
});
import { test } from '@playwright/test';
import { expect } from './fixtures/money-matchers';

test('cart total updates after adding an item', async ({ page }) => {
  await page.goto('/cart');
  await page.getByRole('button', { name: 'Add to cart' }).click();
  // The total is re-rendered by a fetch that has not resolved yet.
  await expect(page.getByTestId('cart-total')).toHaveAmount(49.98);
});

Locally the fetch resolves inside a millisecond and the test is green. In CI, on a loaded runner, it fails with Error: expected 49.98, received 0 — the placeholder value that was on screen the instant the matcher ran. Note that the failure message carries no locator, no call stack pointer into the DOM, and no retry history, so triage from the report alone is guesswork.

Single-shot matcher versus a polling matcher Two timelines: the first matcher reads once at time zero and throws, the second re-reads on a backing-off interval until the value matches inside the timeout budget. Single-shot matcher: reads once, then throws read at t=0 assertion throws while the fetch is still in flight Polling matcher: re-reads until pass or this.timeout value matches 0ms 100ms 250ms 500ms 1s then every 1s Both matchers share the same comparison; only the loop that wraps it differs.
The comparison logic is identical in both matchers — the reliable one simply re-runs it on a backing-off interval instead of trusting the first read.

Step-by-step fix

  1. Put the matcher in its own module and re-export an extended expect. expect.extend() returns a new expect object; it does not mutate the one exported by @playwright/test. Create fixtures/money-matchers.ts, call baseExpect.extend({ ... }), and export the result. Specs that import expect from @playwright/test will never see your matcher, which is the single most common wiring mistake.
  2. Delegate to a built-in web-first assertion instead of reading the DOM yourself. Wrapping baseExpect(locator).toHaveAttribute(...) or .toHaveText(...) in a try/catch inherits the runner's retry loop for free, and the caught error carries a matcherResult object with actual and expected already populated. When no built-in expresses the check, wrap your own comparison in baseExpect.poll() or baseExpect(fn).toPass() rather than awaiting a bare read.
  3. Honour the caller's timeout. Accept an options?: { timeout?: number } parameter and resolve it as options?.timeout ?? this.timeout. this.timeout is the effective expect timeout for the current call, including any value set by expect.configure({ timeout }) or the expect.timeout key in your Playwright config. Hard-coding 5000 inside a matcher makes CI timeout tuning impossible.
  4. Poll in the direction the caller asked for. Read this.isNot and branch: for a negated call, await baseExpect(locator).not.toHaveAttribute(...) so the loop waits for the value to stop matching. If you always poll for the positive condition, expect(...).not.toHaveAmount(5) burns the entire timeout before it can conclude anything, and its verdict describes a moment in the past rather than a settled state.
  5. Return the full matcher result, not just pass. The contract is { pass, message, name, expected, actual }. name is what appears in the HTML report and trace as expect.toHaveAmount; expected and actual drive the structured diff. Build message with this.utils.matcherHint(), this.utils.printExpected(), and this.utils.printReceived() so failures render in the same style as the built-ins.
  6. Merge matcher modules with mergeExpects(). Once you have more than one matcher file, mergeExpects(moneyExpect, a11yExpect, tableExpect) composes them into a single expect that carries every matcher and every type. Export it from the same module that exports your extended test, so a spec's import line stays one line.
  7. Import the merged expect everywhere and enforce it. Re-export test alongside expect from fixtures/index.ts, then add an ESLint no-restricted-imports rule banning a bare expect import from @playwright/test in spec files. This turns a silent missing-matcher runtime error into a lint failure.

Here is the matcher rewritten against all seven points. It is the same comparison as before, wrapped correctly.

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

export const expect = baseExpect.extend({
  async toHaveAmount(
    locator: Locator,
    expected: number,
    options?: { timeout?: number },
  ) {
    const assertionName = 'toHaveAmount';
    // Fall back to the runner's effective expect timeout, never a magic number.
    const timeout = options?.timeout ?? this.timeout;
    let pass: boolean;
    let matcherResult: { actual?: unknown } | undefined;
    try {
      if (this.isNot) {
        // Negated call: poll for the value to STOP matching, then report that
        // the positive condition is false. The runner inverts the verdict.
        await baseExpect(locator).not.toHaveAttribute(
          'data-amount', String(expected), { timeout });
        pass = false;
      } else {
        // Delegating to a built-in gives us the retry loop and a real diff.
        await baseExpect(locator).toHaveAttribute(
          'data-amount', String(expected), { timeout });
        pass = true;
      }
    } catch (e) {
      // Playwright attaches actual/expected to the thrown assertion error.
      matcherResult = (e as { matcherResult?: { actual?: unknown } }).matcherResult;
      pass = this.isNot; // the inner check failed, so the opposite polarity held
    }
    const message = () =>
      this.utils.matcherHint(assertionName, undefined, undefined, { isNot: this.isNot }) +
      '\n\n' +
      `Locator: ${locator}\n` +
      `Expected: ${this.isNot ? 'not ' : ''}${this.utils.printExpected(expected)}\n` +
      (matcherResult ? `Received: ${this.utils.printReceived(matcherResult.actual)}` : '');
    return {
      pass,
      message,
      name: assertionName,           // shows as expect.toHaveAmount in the report
      expected,
      actual: matcherResult?.actual, // feeds the structured diff
    };
  },
});

TypeScript needs no manual declaration merging here: since Playwright 1.39 the object returned by expect.extend() is typed from the matcher signatures, so expect(locator).toHaveAmount(49.98) type-checks wherever you import that expect. Note that the receiver type in the signature (Locator) is what constrains the call site — declare it as Locator rather than any so a mistyped expect(page).toHaveAmount(1) is a compile error rather than a runtime timeout.

Merging matcher modules into one exported expect Three matcher modules feed mergeExpects in a fixtures file, which exports a single extended expect consumed by every spec file. Matcher modules merge into a single exported expect money-matchers.ts a11y-matchers.ts table-matchers.ts mergeExpects() fixtures/expect.ts checkout.spec.ts dashboard.spec.ts reports.spec.ts one extended expect, typed once specs import test and expect from one path
Matchers stay in small domain modules; mergeExpects composes them so every spec imports one expect and inherits every matcher and its types.

Troubleshooting variants

TypeError: expect(...).toHaveAmount is not a function

The spec imported expect from @playwright/test rather than from your module. expect.extend() is non-mutating by design, so the base export is untouched — this is what makes matcher sets composable across projects instead of leaking globally. Fix the import, then prevent the recurrence: export both test and expect from one fixtures barrel, exactly as you would for the fixture wiring in Setting Up Global Fixtures for Parallel Tests, and add the lint rule from step 7. The same symptom appears when a matcher module is imported for side effects only.

TypeError: Cannot read properties of undefined (reading 'utils')

The matcher was written as an arrow function — toHaveAmount: async (locator, expected) => { ... } — so this is the enclosing module scope, not the matcher context. this.utils, this.isNot, this.timeout, and this.promise are only bound when the matcher is a regular function or an object-method shorthand. Use async toHaveAmount(locator, expected) { ... }. The same rule applies to any helper you factor out: pass the context explicitly rather than capturing this in a closure.

The negated form passes when it should fail

This is a double-inversion bug. The runner already decides the verdict from the returned pass and the negation flag, so a matcher that also flips pass when this.isNot is true inverts twice and reports the opposite result. Return pass describing whether the positive condition held, and use this.isNot only to choose the polling direction and to format the message. Always cover both polarities in a self-test — one spec asserting toHaveAmount(49.98) on a matching element and one asserting .not.toHaveAmount(1) — because a matcher that is correct only in the affirmative case looks healthy in every normal run.

The matcher passes but the run is slow

A matcher that catches its inner assertion and retries again in an outer loop multiplies timeouts: a 5 s inner budget inside a 30 s toPass() produces a 30 s failure instead of a 5 s one. Delegate exactly once, propagate the resolved timeout into the inner call, and let the deadline arithmetic described in Configuring Retries and Timeouts for Stable CI stay in one place.

Verification

Prove three properties before the matcher lands. First, determinism under delay: mock the endpoint with page.route() to resolve after two seconds, as in Mocking API Responses with Playwright, and confirm the assertion still passes — that proves the retry loop is real rather than a fast machine hiding the race. Second, message quality: force a failure and read the reporter output. You should see expect.toHaveAmount as the step name, the locator, and an Expected / Received pair; if Received prints undefined, you are not forwarding matcherResult.actual. Third, timeout obedience: call the matcher with { timeout: 1000 } and check the wall-clock duration in the Trace Viewer, where the matcher appears as its own expandable step with a duration. A matcher that ignores this.timeout will run for the config default instead. Finally, confirm expect.soft(locator).toHaveAmount(1) records a failure and continues, which it does automatically once the matcher returns a proper result object — the behaviour covered in Soft Assertions for Multi-Check Tests.

Frequently Asked Questions

When should I write a matcher instead of a helper function?

Write a matcher when the check is an assertion about page state that several specs repeat, and when a failure message benefits from a domain vocabulary — "expected reconciled, received pending" reads better in a report than a boolean comparison. A helper that performs actions, extracts data, or composes several unrelated assertions belongs in a page object instead; see Page Object Model Design for where that boundary sits. The rule of thumb: if the function ends by throwing on a condition about the UI, it wants to be a matcher.

Do custom matchers work with expect.poll and expect.configure?

expect.configure({ timeout: 10_000, soft: true }) returns a configured expect that carries your custom matchers, and the configured timeout arrives in your matcher as this.timeout — which is precisely why step 3 reads it rather than a constant. expect.poll() is a different tool: it wraps a plain function in a retry loop and exposes the built-in value matchers on the result, so it cannot see your custom matcher. Use expect.poll inside a matcher body when no built-in locator assertion expresses your check.

Can a custom matcher assert on something other than a Locator?

Yes. The first parameter is whatever the caller passed to expect(), so you can type it as an APIResponse, a parsed record, or a plain object. Response-shaped matchers pair well with the techniques in Asserting on API Responses Alongside UI. Only locator-based matchers get retry semantics from delegating to a built-in, though — for a value that will not change, a single comparison is correct and a retry loop only adds latency.

How do custom matchers show up in reports and traces?

The name you return becomes the step title, so the HTML reporter and the trace timeline show expect.toHaveAmount with its own duration and the failure message attached. That naming is also what a bespoke reporter reads when it aggregates assertion statistics, which matters if you follow Writing a Custom Playwright Reporter. Omit name and the step falls back to a generic label, making the matcher invisible in per-assertion analytics.

Back to overview