Published January 09, 2023
Simplifying Unit Tests with Custom Matchers: Cleaner Code, Clearer Purpose
Testing Jest Clean-Code JavaScript
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.