Writing Tests for Custom Matchers in Jest

·

4 min read

TLDR: Here's an example of how to write a test for a custom matcher:

describe("toHaveDevDependency", () => {
  it("fails when given a number", () => {
    expect(() => {
      expect(2).toHaveDevDependency("node");
    }).toThrow("Expected 2 to be a YeomanTest.RunResult");
  );
});

Motivation

In the spirit of TDD, I found myself wanting to write tests to ensure my Jest custom matchers work properly.

A quick Google Search for "Jest write tests for custom matcher" only produced results about writing custom matchers themselves when really I wanted to see examples of how to write tests that exercise my custom matchers.

After figuring out how to do this on my own, I thought I'd write the missing article :).

Background

If you're familiar with Jest, you're likely also familiar with custom matchers.

If not, here's the summary version: custom matchers allow you to add your own matching assertions for expect statements. This can be super useful when you find yourself writing the same testing code over and over again and want to DRY up your tests.

Example Situation

This happened recently when writing a Yeoman generator to quickly scaffold new projects. I wanted to write something like expect(result).toHaveDevDependency("typescript") to assert that the package.json file generated with the project includes a specified package in its devDependencies.

The testing helpers provided by Yeoman in yeoman-test include a useful assertJsonFileContent(fileName: string, content: any) method, but as I found myself writing things like

result.assertJsonFileContent(
  "package.json",
  { 
    devDependencies: {
      typescript: "4.7.4"
    }
  },
);

over and over I got tired of the verbosity and realized that this line doesn't state my intent as clearly as it could.

I wanted something like this instead:

expect(result).toHaveDevDependency("typescript", "4.7.4");

Simple, clear, and clean.

My toHaveDevDependency custom matcher ended up being reasonably complicated, including checks to ensure that the received object in the expect call was, indeed, a YeomanTest.RunResult object, and then wrapping the result.assertJsonFileContent() call with a try/catch to translate that result into either a passing or failing jest.CustomMatcherResult with a useful message.

Ironically, the custom matcher ended up showing up in my code coverage reports as the least tested part of my codebase.

The Solution

I wrote myself a test suite for toHaveDevDependency and in the process, I had to figure out how to test an expect statement itself. Rather meta.

First Attempt: wrap expect call in a try/catch

My first attempt looked something like this:

describe("toHaveDevDependency", () => {
  it("fails when given a number", () => {
    try {
      expect(2).toHaveDevDependency("node");
    } catch (error) {
      expect(error.message).toContain("JestAssertionError");
    }
  );
});

The idea being that Jest is likely throwing some kind of error when an expect assertion fails, and perhaps I can catch that error and then do something with it.

Unfortunately, this does not work. Jest seems to be smart enough to fail the test immediately, and while I can catch the error, I couldn't find an easy way to stop the test from failing down this path. There might be a way, but I gave up before I found it.

Second Attempt: wrap raw expect call with expect().toThrow()

My next idea was to use the expect().toThrow() method to tell Jest explicitly that I'm expecting an error throw. It looked like this:

describe("toHaveDevDependency", () => {
  it("fails when given a number", () => {
    expect(
      expect(2).toHaveDevDependency("node");
    ).toThrow("Expected 2 to be a YeomanTest.RunResult");
  );
});

This didn't work, either. The test failed immediately.

Final Working Attempt: wrap expect call in an arrow function and wrap that in an expect().toThrow()

I had one more idea: what if I wrap the expect call in an arrow function first?

This looked like this:

describe("toHaveDevDependency", () => {
  it("fails when given a number", () => {
    expect(() => {
      expect(2).toHaveDevDependency("node");
    }).toThrow("Expected 2 to be a YeomanTest.RunResult");
  );
});

This works! I haven't looked into the Jest internals enough to understand exactly why, but intuitively this makes sense: the arrow function insulates the inner call to expect from the Jest test context, and prevents the function call from being executed immediately within the it block.

Now I can happily write test suites for my custom matchers and test everything with confidence!