Custom Matchers — Extending expect
Stop writing the same 10-line assertion logic over and over. Learn how to use expect.extend to encode complex business rules into clean, auto-retrying custom matchers.
📖 The Story: The 10-Line Assertion
A QA engineer named Lisa was testing a complex data table. The business rule was: "A row is only valid if it has a status of 'Active', its checkbox is unchecked, and its background color is green." Lisa found herself writing this assertion in 30 different test files:
await expect(row.getByRole('status')).toHaveText('Active'); await expect(row.getByRole('checkbox')).not.toBeChecked(); const bgColor = await row.evaluate(el => getComputedStyle(el).backgroundColor); expect(bgColor).toBe('rgb(144, 238, 144)'); // LightGreen
It was 4 lines of messy code, repeated everywhere. When the product team changed the rule (now it needed an "Approved" icon instead of a green background), Lisa had to update 30 test files.
Then Lisa discovered expect.extend. She created a custom matcher called toBeValidActiveRow(). The 4 lines of logic became one: await expect(row).toBeValidActiveRow(). When the business rule changed, Lisa updated exactly one line of code in her matchers file. All 30 tests passed instantly.
If you write the same assertion logic twice, it belongs in a custom matcher.
🎯 Why Custom Matchers?
Readability
Transform 10 lines of DOM checks into a single, English-like assertion.
DRY (Don't Repeat Yourself)
Centralize business logic. Update it in one place when the UI changes.
Auto-Retrying
Custom matchers built on locators inherit Playwright's intelligent polling.
Domain-Driven
Tests read like product requirements (toBeEligibleForCheckout).
🌍 Real World Example
You are testing a finance app. A "Transfer" button is only enabled if the account has sufficient funds, the destination account is valid, and no fraud flag is active. Instead of asserting all three conditions in every transfer test, you write await expect(transferButton).toBeReadyForTransfer(). The matcher encapsulates the 3 checks, making the test intent crystal clear.
🧒 Explain Like I'm 10
Imagine you are a car inspector. Every time you check a car, you have to: check the tires, check the oil, check the brakes, and check the lights. That's 4 steps.
A custom matcher is like inventing a new word: "Car-Ready". Instead of telling your boss, "I checked the tires, oil, brakes, and lights, and they are all good," you just say, "The car is Car-Ready." You teach the system what "Car-Ready" means once, and then you can use your new word everywhere.
🎓 Professional Explanation
Playwright's assertion library is built on top of Jest's expect. Because of this, it exposes the expect.extend API, allowing you to register custom matchers. A custom matcher is a function that receives the actual value (or Playwright Locator) and the expected values, performs logic, and returns an object containing a pass boolean and a message function. When attached to a Locator, these matchers integrate with Playwright's auto-retry loop, re-evaluating until the timeout expires or the condition passes.
📊 Standard vs Custom Flow
How Custom Matchers Simplify Tests
Standard (Repetitive)
Custom Matcher (DRY)
⚙️ The Syntax of expect.extend
Here is how to define a basic custom matcher. Let's create one that checks if a number is even.
import { expect as baseExpect } from '@playwright/test'; export const expect = baseExpect.extend({ toBeEven(actual: number) { const pass = actual % 2 === 0; return { pass, message: () => pass ? `${actual} is even, but expected it to be odd` : `${actual} is odd, but expected it to be even` }; } });
The Return Object: Every matcher must return an object with pass (boolean) and message (a function returning a string). The message is only displayed when the assertion fails (or when .not is used and it passes).
🔄 Auto-Retrying Web-First Matchers
The real power of Playwright is auto-retry. To make a custom matcher auto-retry, it must accept a Locator and use async locator methods inside.
import { expect as baseExpect, Locator } from '@playwright/test'; export const expect = baseExpect.extend({ // Must be async to support auto-retrying async toHaveActiveStatus(locator: Locator, expectedStatus: string) { let pass = false; let actualText = ''; try { // Use locator.innerText for auto-retry actualText = await locator.innerText(); pass = actualText.includes(expectedStatus); } catch (e) { pass = false; } return { pass, message: () => `Expected element to have status "${expectedStatus}", but got "${actualText}"` }; } });
Why innerText? Because innerText is an auto-retrying method in Playwright. If the element isn't rendered yet, it waits. Because the matcher is async, Playwright's expect(locator) will repeatedly invoke this matcher until pass is true or the 5-second timeout expires.
🌍 Global Registration
To use your custom matchers across the entire test suite without importing them in every file, add them to your Playwright config.
import { defineConfig } from '@playwright/test'; import { expect as customExpect } from './matchers'; // Override the default expect globally expect = customExpect; export default defineConfig({ // ... rest of config });
Alternatively, you can create a custom fixture (like we did in Chapter 10) to inject the expect object into your tests. The fixture approach is often cleaner for large enterprise apps.
⚠️ Common Mistakes
Mistake 1: Using Non-Retrying Methods
// ❌ BAD: 'textContent' does not auto-retry. The matcher fails instantly. const text = await locator.evaluate(el => el.textContent); // ✅ GOOD: 'innerText()' auto-retries up to 5 seconds. const text = await locator.innerText();
Mistake 2: Forgetting the message Function
The message property must be a function that returns a string. If you just provide a string, Playwright will throw a configuration error when the matcher is invoked.
✅ Best Practices
- Match Domain Language. Name matchers after business rules (
toBeReadyForCheckout), not technical details (toHaveGreenColorAndActiveText). - Keep Matchers Focused. A matcher should verify one logical concept. Don't make a matcher that checks 10 unrelated things.
- Always Return a Helpful Message. When the test fails, the message should tell the developer exactly what was expected vs. what was received.
- Use TypeScript. Type the
actualandexpectedparameters so your IDE provides autocompletion.
🏗️ Senior Engineer Deep Dive
TypeScript Declaration Merging
If you use TypeScript, the custom matcher won't show up in autocomplete until you declare it. You must use declaration merging to extend the PlaywrightTestMatchersAndUtils interface.
import { PlaywrightTestMatchersAndUtils } from '@playwright/test'; declare module '@playwright/test' { interface PlaywrightTestMatchersAndUtils { toHaveActiveStatus(expectedStatus: string): Promise<void>; } }
The .not Modifier
Playwright automatically handles the .not modifier for you. If you write expect(el).not.toHaveActiveStatus('Active'), Playwright inverts the pass boolean. Your message function, however, should account for both states (pass and fail) to provide accurate error text in both scenarios.
💼 Interview Questions
Show Answer
expect.extend() API, which allows you to define custom matchers. You write a function that evaluates the condition and returns a pass boolean and a message string.Show Answer
async and accept a Playwright Locator. Inside the matcher, I use auto-retrying locator methods like innerText() or isVisible(). Because it returns a promise, Playwright's expect loop will re-invoke the matcher until it passes or times out.Show Answer
.d.ts file, import the PlaywrightTestMatchersAndUtils interface, and add my custom method signature to it. This extends the global type definition.Show Answer
toBeEligible using expect.extend. The matcher would accept a locator (e.g., the user card) and internally perform the 3 checks using toBeVisible and toHaveText. The tests would then simply read await expect(userCard).toBeEligible(), centralizing the logic.🏋️ Practical Exercise
Create a Custom Matcher
- Create a file named
matchers.ts. - Use
expect.extendto create a matcher calledtoHaveFontSizethat accepts a Locator and a pixel size. - Inside the matcher, use
evaluateto readgetComputedStyle(el).fontSize. - Write a test that uses
await expect(page.locator('h1')).toHaveFontSize('32px').
🚀 Mini Project
🏗️ The Domain-Driven Matcher
- Find a complex UI state on a public site (e.g., an item is "In Stock" and "On Sale").
- Write a standard test asserting this state (multiple lines).
- Refactor: Create a custom matcher
toBeAvailableAndDiscountedthat encapsulates those checks. - Update your test to use the custom matcher.
- Add the TypeScript declaration so your IDE recognizes the new method.
📝 Quiz
Test your understanding. Click an option to check your answer.
expect.add()expect.extend()test.extend()customMatchers.create()expect.extend() allows you to add custom matcher functions to the assertion library.pass and message properties{ pass: boolean, message: () => string }.retry: true in the configasync and use auto-retrying locator methods insidepage.waitForinnerText(), Playwright polls it.message property be a function rather than a string?.not modifier with custom matchers?notToBe matcher.pass boolean for you..not is not supported.not: true to the return object.pass value.locator.innerText()locator.getAttribute()locator.evaluate(el => el.textContent)locator.isVisible()evaluate does not auto-retry; it reads the DOM once and fails instantly if the element isn't ready.matchers.ts file, imported globally or via configpackage.json filePlaywrightTestMatchersAndUtils interfacetoBeReadyForCheckout)❓ FAQ
Q: Can custom matchers accept multiple arguments?
A: Yes. The first argument is always the actual value (the locator). Subsequent arguments are whatever you pass in the matcher: expect(loc).customMatcher(arg1, arg2).
Q: Can I use expect.extend to override built-in matchers like toBeVisible?
A: Technically yes, but it is highly discouraged. Overriding built-ins can cause unpredictable behavior and break third-party plugins. Always use unique names.
📦 Summary
🎯 Key Takeaways
- Use
expect.extendto create custom assertions. - Return
{ pass, message }from the matcher function. - Make them async and use locator methods to inherit Playwright's auto-retry.
- Name matchers after business rules (e.g.,
toBeEligible). - Use TypeScript declaration merging to get autocomplete working.
- Never override built-in matchers like
toBeVisible.
