Cleaner Unit Tests with Custom Matchers
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
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.
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK