How do you add a custom matcher to Playwright's expect, and what must that matcher return?
answer
- Alias the import, export the result
- Extend hands back a new expect
- Return pass plus a message function
- Message is lazy, built on demand
- Delegate inside for the retrying behaviour
basics
~20 sImport expect as baseExpect from @playwright/test, call baseExpect.extend with your matcher functions, and export the result as expect. Each matcher returns an object with a boolean pass and a message function that builds the failure text.
solid answer
~50 sThe pattern is `import { expect as baseExpect } from '@playwright/test'`, then `export const expect = baseExpect.extend({ async toHaveAmount(locator, expected, options) { ... } })`. Tests import that `expect` instead of the package's, which also gives TypeScript the new matcher, because `extend` returns a new, more widely typed expect rather than mutating the original. A matcher receives the asserted value as its first argument and returns `{ pass, message }` - `pass` answers the positive form of the assertion, and `message` is a **function** returning the string shown on failure, so it is only built when needed. Optional `name`, `expected` and `actual` improve the output. To get retrying behaviour, await an existing web-first assertion inside a try/catch and forward the caller's `timeout`; use `this.isNot` only to word the message, since the runner handles `.not` itself.
code
typescript · 27 linesimport { expect as baseExpect, type Locator } from '@playwright/test';
export const expect = baseExpect.extend({
async toHaveAmount(
locator: Locator,
expected: string,
options?: { timeout?: number },
) {
let pass = true;
try {
// Delegate so the check retries until the timeout.
await baseExpect(locator).toHaveAttribute('data-amount', expected, options);
} catch {
pass = false;
}
const actual = await locator.getAttribute('data-amount');
return {
name: 'toHaveAmount',
pass,
expected,
actual,
message: () =>
`Expected balance ${this.isNot ? 'not ' : ''}to be ${expected}, received ${actual}`,
};
},
});go deeper
Know that expect can be extended with your own matchers and that tests must import the extended expect rather than the package's, or the new matcher will not exist.
Explain the return object: a boolean pass for the positive assertion and a message function that builds the failure text lazily, plus optional name, expected and actual for the diff.
Demonstrate the retrying construction - delegate to an existing web-first assertion inside try/catch, forward the timeout option - and handle negation by wording the message rather than flipping pass.
Weigh vocabulary against surface area: a matcher earns its place when a domain assertion recurs and the default message would send readers to the source, otherwise it is API nobody asked for.
## Why a custom matcher at all A bank statement page has domain vocabulary - a balance is an *amount*, a row is a *transaction*. A helper function can check an amount, but it produces a generic failure and reads like plumbing. A matcher lets the test say `await expect(balance).toHaveAmount('1204.55')` and, when it fails, print a message you wrote about amounts. That is the whole trade: a little wiring for assertions that read in the language of the product. ## The extension model ```ts import { expect as baseExpect } from '@playwright/test'; export const expect = baseExpect.extend({ async toHaveAmount(locator, expected, options) { /* ... */ }, }); ``` Three details in those four lines matter: - **`extend` returns a new expect.** It does not patch the package's export in place, so the returned object is what your tests must use. - **Aliasing frees the name.** Importing as `baseExpect` lets the extended instance be exported as `expect`, so test files change only their import path, not their assertions. - **Types come along.** Because the extended instance is a new, more widely typed object, TypeScript sees `toHaveAmount` on it - which it would not on the package's own `expect`. ## The return contract | Field | Required | Meaning | |---|---|---| | `pass` | yes | Boolean answer to the **positive** form of the assertion | | `message` | yes | Function returning the failure text, called only when needed | | `name` | no | Matcher name shown in the output | | `expected` | no | Value the assertion wanted, for the diff | | `actual` | no | Value it observed, for the diff | Two rules trip people up. `message` is a **function**, not a string - returning a string means the text is built on every call, including the thousands that pass. And `pass` should answer the plain assertion; the runner applies `.not` itself, so inverting `pass` yourself makes negated assertions quietly wrong. `this.isNot` exists so the *message* can read correctly in both directions. ## Making it retry A matcher that reads a value once is a snapshot, and on a page that is still settling it will be flaky. The reliable construction delegates to an assertion that already retries: 1. Call an existing web-first assertion on the locator inside `try`, forwarding the caller's `options` so a `timeout` argument is honoured. 2. If it resolves, set `pass = true`; if it throws, set `pass = false`. 3. Read the observed value afterwards for the message and the `actual` field. 4. Return `{ pass, message, name, expected, actual }`. The waiting then comes from the inner assertion, and your matcher only adds vocabulary and a better message. ## Wiring and boundaries - Put the extended expect in its own module and import `expect` from there in the tests that need it. - Keep the matcher **thin**: locate nothing, navigate nowhere, assert one property. - Give it a **name field** matching the method name so failures identify themselves. - Use `this.utils` when you want the runner's own value formatting inside the message. ## When a matcher is not the answer - The check is used **once** - a plain assertion with a message argument is clearer than a new API. - The logic is really a **sequence of actions**; that is a page-object method, not a matcher. - You want to check several unrelated things at once - split them, or the failure message cannot say which part failed. A good rule: write the matcher when the same domain assertion appears in several tests and the built-in failure message would send a reader to the source to work out what was actually being checked.
- Why is message a function rather than a string?So it is built only when the text is actually needed. A passing assertion never renders its message, and formatting values - reading attributes, printing diffs - is wasted work on the happy path, which for a matcher used across a suite is most of the calls.
- How does a custom matcher end up retrying rather than sampling once?By awaiting something that already retries. Wrap an existing web-first assertion in try/catch and forward the caller's timeout option; the polling is the inner assertion's, and your matcher contributes only the name, the message and the expected/actual values.
- What should pass be when the assertion is used with .not?The same value as without it. Return the answer to the positive question and let the runner invert it; `this.isNot` is for wording the message so it reads correctly in the negated direction, not for flipping the result yourself.
saying these in an interview costs you the question
- Returning a message string instead of a message function
- Inverting pass yourself so negated assertions check backwards
- Reading a value once, so the matcher never retries
- Still importing expect from the package after extending
- Writing a message that reads wrong under a negation