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.
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.
Step-by-step fix
- Put the matcher in its own module and re-export an extended
expect.expect.extend()returns a newexpectobject; it does not mutate the one exported by@playwright/test. Createfixtures/money-matchers.ts, callbaseExpect.extend({ ... }), and export the result. Specs that importexpectfrom@playwright/testwill never see your matcher, which is the single most common wiring mistake. - Delegate to a built-in web-first assertion instead of reading the DOM yourself. Wrapping
baseExpect(locator).toHaveAttribute(...)or.toHaveText(...)in atry/catchinherits the runner's retry loop for free, and the caught error carries amatcherResultobject withactualandexpectedalready populated. When no built-in expresses the check, wrap your own comparison inbaseExpect.poll()orbaseExpect(fn).toPass()rather than awaiting a bare read. - Honour the caller's timeout. Accept an
options?: { timeout?: number }parameter and resolve it asoptions?.timeout ?? this.timeout.this.timeoutis the effectiveexpecttimeout for the current call, including any value set byexpect.configure({ timeout })or theexpect.timeoutkey in your Playwright config. Hard-coding5000inside a matcher makes CI timeout tuning impossible. - Poll in the direction the caller asked for. Read
this.isNotand branch: for a negated call, awaitbaseExpect(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. - Return the full matcher result, not just
pass. The contract is{ pass, message, name, expected, actual }.nameis what appears in the HTML report and trace asexpect.toHaveAmount;expectedandactualdrive the structured diff. Buildmessagewiththis.utils.matcherHint(),this.utils.printExpected(), andthis.utils.printReceived()so failures render in the same style as the built-ins. - Merge matcher modules with
mergeExpects(). Once you have more than one matcher file,mergeExpects(moneyExpect, a11yExpect, tableExpect)composes them into a singleexpectthat carries every matcher and every type. Export it from the same module that exports your extendedtest, so a spec's import line stays one line. - Import the merged
expecteverywhere and enforce it. Re-exporttestalongsideexpectfromfixtures/index.ts, then add an ESLintno-restricted-importsrule banning a bareexpectimport from@playwright/testin 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.
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.