When unit testing, it’s important to cover all your edge cases, but that can come at a cost. Covering edge cases often means making the same or similar assertions over and over again. While test names should clearly describe what is being tested, sometimes these assertions can be messy and have an unclear purpose. Using custom matchers in your tests can help make your assertions cleaner and less ambiguous.

Note: the example used in this article is written using the Jest testing framework.

Let’s take a look at an example. I needed to test several cases to see if cacheable-lookup was installed on an Agent. cacheable-lookup adds some symbol properties to any Agent it’s installed on. We just need to look at the agent’s symbol properties and see if they exist there. The assertion may look something like this:

expect(Object.getOwnPropertySymbols(http.globalAgent)).toEqual(expect.arrayContaining(cacheableLookupSymbols));

So when we are testing that cacheable-lookup gets successfully uninstalled our spec would be similar to the below.

it('should uninstall cacheable lookup when DNS cache is not enabled and cacheable lookup is installed', () => {
  installCacheableLookup();
  expect(Object.getOwnPropertySymbols(http.globalAgent)).toEqual(expect.arrayContaining(cacheableLookupSymbols));
  expect(Object.getOwnPropertySymbols(https.globalAgent)).toEqual(expect.arrayContaining(cacheableLookupSymbols));
  setupDnsCache();
  expect(Object.getOwnPropertySymbols(http.globalAgent)).not.toEqual(expect.arrayContaining(cacheableLookupSymbols));
  expect(Object.getOwnPropertySymbols(https.globalAgent)).not.toEqual(expect.arrayContaining(cacheableLookupSymbols));
});

Now we’ve got a working test, but it’s quite repetitive and a little hard to read, a problem that will just be exacerbated when we add more use cases. It’s also not very clear to the next engineer to come across our code what the significance is of each of these assertions. Grokable tests can act as an extension of your documentation, and we’re missing out on that here. Let’s refactor this with a custom matcher to make it DRY, more readable, and more easily comprehendable.

We’ll do this by calling expect.extend, and to keep things simple we’ll reuse the same toEqual matcher from before. Reusing the built-in matchers means that there are fewer implementation details for us to worry about in our custom matcher.

Keeping the matcher in the same file as the tests will reduce indirection and keep the tests grokable. It’s important that we keep it easy for others to understand what exactly the matcher is doing, and, since the matcher is added globally to expect, that can become difficult if we move the matcher to a different file.

Now, let’s give the matcher a really explicit name that tells us exactly what the assertion is checking for, toHaveCacheableLookupInstalled.

import matchers from 'expect/build/matchers';

expect.extend({
  toHaveCacheableLookupInstalled(input) {
    return matchers.toEqual.call(
      this,
      Object.getOwnPropertySymbols(input.globalAgent),
      expect.arrayContaining(cacheableLookupSymbols)
    );
  },
});

Now that we have our custom matcher, we’re ready to refactor those assertions.

it('should uninstall cacheable lookup when DNS cache is not enabled and cacheable lookup is installed', () => {
  installCacheableLookup();
  expect(http).toHaveCacheableLookupInstalled();
  expect(https).toHaveCacheableLookupInstalled();
  setupDnsCache();
  expect(http).not.toHaveCacheableLookupInstalled();
  expect(https).not.toHaveCacheableLookupInstalled();
});

Now our tests are cleaner, but our failure message is not great. Reusing a built-in matcher worked well for us to get things running quickly, but it does have its limitations. Take a look at what we see if we comment out the function that is uninstalling cacheable-lookup.

  ● setupDnsCache › should uninstall cacheable lookup when DNS cache is not enabled and cacheable lookup is installed

    expect(received).not.toEqual(expected) // deep equality

    Expected: not ArrayContaining [Symbol(cacheableLookupCreateConnection), Symbol(cacheableLookupInstance)]
    Received:     [Symbol(kCapture), Symbol(cacheableLookupCreateConnection), Symbol(cacheableLookupInstance)]

      59 |     expect(https).toHaveCacheableLookupInstalled();
      60 |     // setupDnsCache();
    > 61 |     expect(http).not.toHaveCacheableLookupInstalled();
         |                      ^
      62 |     expect(https).not.toHaveCacheableLookupInstalled();
      63 |   });
      64 |

It’s the same as before the refactor, but now it’s worse because the matcher hint still says toEqual even though we’re now using toHaveCacheableLookupInstalled. If we were to write a custom matcher from scratch we could make this test more effective. We can fix the hint and add a custom error message with a more explicit description of the failure.

expect.extend({
  toHaveCacheableLookupInstalled(input) {
    const { isNot } = this;
    const options = { secondArgument: '', isNot };
    const pass = this.equals(Object.getOwnPropertySymbols(input.globalAgent), expect.arrayContaining(cacheableLookupSymbols))
    return {
      pass,
      message: () => `${this.utils.matcherHint('toHaveCacheableLookupInstalled', undefined, '', options)
      }\n\nExpected agent ${this.isNot ? 'not ' : ''}to have cacheable-lookup's symbols present`,
    };
  },
});

Here we’ve used this.equals to do our comparison, and this.utils.matcherHint to fix the name of our matcher in the hint. this.utils.matcherHint is not very well documented, so you may have to source dive to better understand the API. The order of arguments is matcherName, received, expected, and finally options. Using an empty string for expected prevents the hint from looking like our matcher requires an expected value.

See how greatly this improved our error message:

  ● setupDnsCache › should uninstall cacheable lookup when DNS cache is not enabled and cacheable lookup is installed

    expect(received).not.toHaveCacheableLookupInstalled()

    Expected agent not to have cacheable-lookup's symbols present

      61 |     expect(https).toHaveCacheableLookupInstalled();
      62 |     // setupDnsCache();
    > 63 |     expect(http).not.toHaveCacheableLookupInstalled();
         |                      ^
      64 |     expect(https).not.toHaveCacheableLookupInstalled();
      65 |   });
      66 |

We’ve already made some great improvements to our test suite, but we can make it even better. By further customizing our matcher and getting away from the simple this.equals, we can make our test assert not only that all of the symbols are present when cacheable-lookup is installed, but that none of them are present when it shouldn’t be installed rather than just “not all of them.” We’ll use this.isNot to conditionally use Array.prototype.some or Array.prototype.every when we look for the symbols on the agent depending on whether cacheable-lookup should be installed.

expect.extend({
  toHaveCacheableLookupInstalled(input) {
    const { isNot } = this;
    const options = { secondArgument: '', isNot };
    const agentSymbols = Object.getOwnPropertySymbols(input.globalAgent);
    const pass = isNot
      ? cacheableLookupSymbols.some((symbol) => agentSymbols.includes(symbol))
      : cacheableLookupSymbols.every((symbol) => agentSymbols.includes(symbol));
    return {
      pass,
      message: () => `${this.utils.matcherHint('toHaveCacheableLookupInstalled', undefined, '', options)
      }\n\nExpected agent ${isNot ? 'not ' : ''}to have cacheable-lookup's symbols present`,
    };
  },
});

Now on top of having a clean, DRY test that’s easy to understand and a matcher that we can reuse throughout the rest of our test suite, we have assertions that are even more effective than the simple (but hard to read) toEqual check we started with.

Remember, keeping your custom matcher at the top of the same file that the tests using it are in is vital to its usefulness. If you do not, other engineers may not know that it is a custom matcher and not know where it comes from. The last thing you want is for your teammates to waste hours searching the internet for documentation on a matcher that doesn’t exist outside your codebase. It’s also important that your matcher is easily understandable. Personally I’m partial to cacheableLookupSymbols.every(agentSymbols.includes.bind(this)), but being explicit in our matcher provides more value than being terse.

Check out the original pull request to One App that inspired this blog post.