4

Cleaner Unit Tests with Custom Matchers

 1 year ago
source link: https://americanexpress.io/cleaner-unit-tests-with-custom-matchers/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Cleaner Unit Tests with Custom Matchers


American Express Staff Engineer

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:

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

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.

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

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.

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.

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:

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.

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.

Important Notice: Opinions expressed here are the author’s alone. While we're proud of our engineers and employee bloggers, they are not your engineers, and you should independently verify and rely on your own judgment, not ours. All article content is made available AS IS without any warranties. Third parties and any of their content linked or mentioned in this article are not affiliated with, sponsored by or endorsed by American Express, unless otherwise explicitly noted. All trademarks and other intellectual property used or displayed remain their respective owners'. This article is © 2023 American Express Company. All Rights Reserved.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK