Introduction

Imagine you are testing a web application and want to verify that a specific element contains some text. With the Playwright’s built-in assertions, you might write something like this:

await expect(page.locator('.user-greeting')).toContainText('Welcome back, John!');

This works, but it’s not flexible. What if you want to check for a partial match? Or what if you want to make this case insensitive? You’d end up with something like this:

const text = await page.locator('.user-greeting').textContent();
expect(text?.toLowerCase()).toContain('welcome back');

While functional, it’s not exactly elegant. And if you need to do this kind of check in multiple places, you’ll end up with repetitive, hard-to-maintain code.

A good test is like a well-written book — easy to read and understand, without clutter that detracts from its purpose.

This is where custom matchers shine. They allow you to create specific, tailored assertions that enhance Playwright’s built-in capabilities, making your tests cleaner, more readable, and easier to maintain.

In this article, we’ll explore how to create and use custom matchers in Playwright — starting from a simpler approach to more complex scenarios.

Creating Your First Custom Matcher

Let’s create a custom matcher called ‘toHaveTextContent’ to solve the problem we saw earlier. Here’s how it looks:

import { expect, Locator } from "@playwright/test";

const customMatchers = {

  async toHaveTextContent(

    locator: Locator,

    expectedText: string,

    options = { caseSensitive: true },

  ) {

    const actualText = await locator.textContent();

    let pass: boolean;

    if (options.caseSensitive) {

      pass = actualText?.includes(expectedText) ?? false;

    } else {

      pass =

        actualText?.toLowerCase().includes(expectedText.toLowerCase()) ?? false;

    }

    return {

      pass,

      message: () =>

        pass

          ? `Expected element not to have text content "${expectedText}"`

          : `Expected element to have text content "${expectedText}", but found "${actualText}"`,

    };

  },

};

expect.extend(customMatchers);

declare global {

  namespace PlaywrightTest {

    interface Matchers<R> {

      toHaveTextContent(

        expectedText: string,

        options?: { caseSensitive?: boolean },

      ): Promise<R>;

    }

  }

}

At first glance, this might look complex, so let’s break it down:

  • We define our matcher function, which takes a Locator, the expected text, and an options object.
  • We get the actual text content of the element.
  • We perform our check, considering the case sensitivity option.
  • We return an object with the result and appropriate error messages.

The expect.extend and declare global parts are TypeScript magic that makes our custom matcher play nicely with Playwright’s existing assertion system.

Using Our New Matcher

With our new matcher in place, we can now write our test like this:

await expect(page.locator('.user-greeting')).toHaveTextContent('Welcome back', { caseSensitive: false });

Isn’t this much cleaner? And the best part is, we can reuse this matcher across our entire test suite!

Taking It Further: Creating Custom Matchers with Page Objects

As our test suite grows, we often turn to the Page Object Model to keep things organised. Let’s see how we can create a more complex custom matcher that works with page objects.

First, let’s define a simple page object for a hypothetical user dashboard:

class UserDashboard {

  constructor(private page: Page) {}

  async getUserGreeting() {

    return this.page.locator(".user-greeting");

  }

  async getLatestNotification() {

    return this.page.locator(".notification").first();

  }

}

Now, let’s create a custom matcher that checks if the user has a new notification:

const customMatchers = {

  // ... our previous matcher ...

  async toHaveNewNotification(dashboard: UserDashboard) {

    const notification = await dashboard.getLatestNotification();

    const isVisible = await notification.isVisible();

    const text = await notification.textContent();

    return {

      pass: isVisible && text?.includes("New"),

      message: () =>

        isVisible && text?.includes("New")

          ? `Expected user not to have a new notification, but found: "${text}"`

          : `Expected user to have a new notification, but found none`,

    };

  },

};

expect.extend(customMatchers);

declare global {

  namespace PlaywrightTest {

    interface Matchers<R> {

      // ... our previous matcher type ...

      toHaveNewNotification(): Promise<R>;

    }

  }

}

With this setup, we can use our custom matcher in tests like so:

test('user sees new notification', async ({ page }) => {

  const dashboard = new UserDashboard(page);

  await page.goto('/dashboard');



  await expect(dashboard).toHaveNewNotification();

});

Why Custom Matchers Are Crucial for Playwright Testing

By now, the potential of custom matchers should be clear. They allow us to:

  • Encapsulate Complex Logic: Intricate assertions can be simplified into reusable, modular functions.
  • Enhance Readability: Tests are cleaner, more expressive, and easier to understand.
  • Reduce Code Duplication: Reusing the same logic across different tests makes them more efficient and easier to maintain.
  • Create a Domain-Specific Language for Tests: Using words and logic relevant to the project helps both developers and non-developers quickly understand the purpose of the test.

At CAW, we have experienced that investing time in creating good custom matchers pays off enormously as a test suite grows. They’re especially useful when you’re working with a team, as they allow you to create a shared vocabulary for your tests.

Best Practices and Tips to Make the Most of Custom Matchers

  • Keep it Simple: Start with simple matchers and only add complexity as needed.
  • Be Descriptive: Use clear, descriptive names for your matchers. toHaveNewNotification is much better than toHaveNot.
  • Error Messages Matter: Spend time crafting helpful error messages. Your future self (or your teammates) will thank you when debugging failing tests.
  • Use TypeScript: The type safety and auto-completion are invaluable when working with custom matchers.
  • Don’t Overdo It: Not everything needs to be a custom matcher. Use them for common, complex, or domain-specific assertions.

Wrapping Up

Custom matchers in Playwright have become an essential part of CAW’s testing toolkit. They’ve allowed us to write cleaner, more expressive tests while significantly reducing the time spent maintaining our test suite.

The examples we’ve looked at here are just the tip of the iceberg. The real power of custom matchers is seen when you start creating ones that are specific to your application and business domain. Remember to start with simple matchers for common use cases and gradually expand your matcher library as your testing needs grow.

If you need support implementing custom matchers in your Playwright testing workflow, let’s chat! CAW can take over your entire test automation suite, reducing QA time to weeks and enabling faster, confident deployments.

FAQs

Why should you use custom matchers in Playwright tests?

Custom matchers allow you to simplify complex assertions logic, reduce code duplication, and make tests more readable and expressive. They also help build a domain-specific language for your tests, making them easier to understand for both developers and non-developers.

When should you implement custom matchers in your test suite?

You should consider custom matchers when you have repetitive complex assertions across multiple tests. They help maintain consistency and readability as your test suite grows, especially in large projects with specific business logic.

Can custom matchers be shared between projects?

Yes, you can create a shared library of matchers that can be imported into different projects.