📖 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:

typescript · Repetitive Logic
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)
Test File A: 4 lines of logic
Test File B: 4 lines of logic
Test File C: 4 lines of logic
⬇️ UI Changes?
Update 3 files manually
Custom Matcher (DRY)
Test File A: toBeValidRow()
Test File B: toBeValidRow()
Test File C: toBeValidRow()
⬇️ UI Changes?
Update matchers.ts once

⚙️ 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.

typescript · matchers.ts
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.

typescript · matchers.ts
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.

typescript · playwright.config.ts
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

typescript · ❌ No auto-retry
// ❌ 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

  1. Match Domain Language. Name matchers after business rules (toBeReadyForCheckout), not technical details (toHaveGreenColorAndActiveText).
  2. Keep Matchers Focused. A matcher should verify one logical concept. Don't make a matcher that checks 10 unrelated things.
  3. Always Return a Helpful Message. When the test fails, the message should tell the developer exactly what was expected vs. what was received.
  4. Use TypeScript. Type the actual and expected parameters 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.

typescript · global.d.ts
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

Beginner
Can you create custom assertions in Playwright?
Show Answer
Yes. Playwright exposes the 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.
Intermediate
How do you make a custom matcher auto-retry in Playwright?
Show Answer
The custom matcher function must be 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.
Advanced
How do you add a custom matcher to TypeScript's autocomplete?
Show Answer
I use TypeScript declaration merging. I create a .d.ts file, import the PlaywrightTestMatchersAndUtils interface, and add my custom method signature to it. This extends the global type definition.
Scenario
Your team has a complex rule for what makes a user "Eligible" on a page. It requires checking 3 different elements. How do you simplify the tests?
Show Answer
I would create a custom matcher called 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

🎯 Hands-On Practice Medium

Create a Custom Matcher

  1. Create a file named matchers.ts.
  2. Use expect.extend to create a matcher called toHaveFontSize that accepts a Locator and a pixel size.
  3. Inside the matcher, use evaluate to read getComputedStyle(el).fontSize.
  4. Write a test that uses await expect(page.locator('h1')).toHaveFontSize('32px').

🚀 Mini Project

🏗️ The Domain-Driven Matcher

  1. Find a complex UI state on a public site (e.g., an item is "In Stock" and "On Sale").
  2. Write a standard test asserting this state (multiple lines).
  3. Refactor: Create a custom matcher toBeAvailableAndDiscounted that encapsulates those checks.
  4. Update your test to use the custom matcher.
  5. Add the TypeScript declaration so your IDE recognizes the new method.

📝 Quiz

Test your understanding. Click an option to check your answer.

1. Which API is used to create custom matchers in Playwright?
A) expect.add()
B) expect.extend()
C) test.extend()
D) customMatchers.create()
Answer: B — expect.extend() allows you to add custom matcher functions to the assertion library.
2. What must a custom matcher function return?
A) A boolean
B) A string (the error message)
C) An object with pass and message properties
D) A Promise
Answer: C — It must return { pass: boolean, message: () => string }.
3. How do you make a custom matcher auto-retry?
A) Set retry: true in the config
B) Make the matcher async and use auto-retrying locator methods inside
C> Wrap it in page.waitFor
D) All matchers auto-retry automatically
Answer: B — If the matcher is async and uses methods like innerText(), Playwright polls it.
4. What is a primary benefit of custom matchers?
A) They run faster than standard matchers.
B) They allow you to bypass security rules.
C) They centralize complex business logic, making tests DRY and readable.
D) They are required for database testing.
Answer: C — They encode multi-step logic into a single, reusable assertion.
5. Why must the message property be a function rather than a string?
A> It's faster to evaluate.
B) Playwright requires it to lazily evaluate the string only when the test fails.
C) To support multiple languages.
D) Strings are not supported in JSON.
Answer: B — If it were a string, it would be constructed on every check; a function is only called when the assertion actually fails.
6. How does Playwright handle the .not modifier with custom matchers?
A) You must write a separate notToBe matcher.
B) Playwright automatically inverts the pass boolean for you.
C) It throws an error; .not is not supported.
D) You must add not: true to the return object.
Answer: B — Playwright handles the inversion automatically based on the pass value.
7. Which method should you AVOID inside an auto-retrying custom matcher?
A) locator.innerText()
B) locator.getAttribute()
C> locator.evaluate(el => el.textContent)
D) locator.isVisible()
Answer: C — evaluate does not auto-retry; it reads the DOM once and fails instantly if the element isn't ready.
8. Where should you define custom matchers for global access?
A) Inside every test file
B) In a separate matchers.ts file, imported globally or via config
C) In the package.json file
D) Inside the Page Object Model
Answer: B — A dedicated matchers file keeps them organized and easy to import globally.
9. How do you add autocomplete support for a custom matcher in TypeScript?
A) Install a VS Code extension
B) Use declaration merging to extend the PlaywrightTestMatchersAndUtils interface
C) Export the matcher as a string
D) You cannot; TypeScript does not support it
Answer: B — Declaration merging adds the method signature to the existing interface.
10. What is a good naming convention for a custom matcher?
A) Name it after the CSS classes it checks
B) Name it after the business domain rule (e.g., toBeReadyForCheckout)
C) Name it after the developer who wrote it
D) Use random strings
Answer: B — Matchers should read like product requirements for maximum readability.

❓ 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.extend to 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.