How to Make Your Own Hamcrest Matchers in Kotlin
A Match made in Hamcrest-heaven!
Join the DZone community and get the full member experience.
Join For FreeIntro to Hamcrest Matchers
First things first, I should quickly explain what a Hamcrest Matcher is. When conducting unit tests, the built-in assertion types that come with the testing framework are generally pretty limited. They make it very easy for a person to end up with multiple asserts to essentially check one thing. Even if it doesn't contain multiple asserts, those asserts aren't the most fluent to read and don't tell exactly what you're checking.
That's where Hamcrest Matchers come in (and other assertion libraries, but we're looking at Hamcrest right now). They allow you to define your own more robust and more fluent assertions, essentially. For example, if you were testing whether a method correctly returns an empty String, that test might look something like this:
@Test
fun testUsingMatcher() {
val string = methodThatShouldReturnAnEmptyString();
assertThat(string, isEmptyString());
}
Look at that assertion line. It almost reads like English once you ignore all the "punctuation". "Assert that string is an empty string." Some matchers put more effort into reading like English than others, but they all read well enough to make them easy to understand.
Anatomy of a Hamcrest Matcher
We'll go through the basics, recreating the IsEmptyString
matcher along the way. First off, this matcher will extend from <code-BaseMatcher. With that, let's start building.
There are basically four parts to a Hamcrest Matcher: a static factory method, an assertion, a description of a passed assertion, and a description of a failed assertion. The last two are the biggest reason why I wrote this article. It took me a while with a fair bit of tinkering to figure out the "best practices" for that. But we'll start at the top.
In all honesty, the static factory method isn't an issue in Kotlin. the primary reason for it was to not need the new
keyword, but Kotlin doesn't use it anyway. If you're planning on making it backward compatible for use in Java code, too, then I recommend still using it. If not, then the only thing you need to contend with is capitalization. Are you okay with a capitalized class name being used in the assertion? If so, then you've got it easy. If not, you have the option of either making the class name lower-cased (thus breaking convention) or adding a static factory method. There's also always the import ... as ...
option that Kotlin so graciously provides.
If you're going to make the static factory, I actually recommend doing it as a top-level function, rather than as a companion object
. This makes it automatically a static method for Java users, so they can do a nice static import, and it allows you to avoid the strange companion syntax (especially paired with @JvmStatic
.
This code for the function is as simple as this:
xxxxxxxxxx
fun isEmptyString() = IsEmptyString()
Overall, the method is very bland and obvious. I have yet to run into an instance where it's not. It's also especially quick and easy with all the shortcuts Kotlin allows, such as skipping the return type and using the single expression form.
Next, we need the assertion, which is done with the matches()
method. This is the real work of a matcher, running the actual check. Notice that it's not meant to throw the AssertionError
; that's the job of the assertThat()
method. This method simply returns a Boolean
stating whether the input matches the idea the Matcher tests for.
The input into this method comes from the first argument in the assertThat()
method. When assertThat()
runs the provided matcher's matches()
method, it passes that argument into it. The matches()
method takes in an Object
( Any
in Kotlin), not the type specified by the generics. This is because of some weird "feature" of Java generics that I haven't looked into, so I can't explain it. I just wanted you to know that you will generally have to do some sort of type checking when you just extend BaseMatcher
.
Here's the implementation of our IsEmptyString
's matches()
method:
xxxxxxxxxx
override fun matches(actual: Any?): Boolean =
if (actual is String)
actual == ""
else
false
That's all there is to it in this case — it just checks whether it's an empty String.
Now, we have to do the two descriptions. We'll do these in tandem since they're similar. The two descriptions are describeTo()
and describeMismatch()
. The describeTo()
method is used to describe what is expected by the matcher. In this case, that's an empty String
. The describeMismatch()
method is for describing the actual result, usually just outputting the given object.
The two description methods only come into play if the match fails and the AssertionError
is thrown. The assertThat()
method then builds a Description for the failed assertion. It is formatted as follows:
xxxxxxxxxx
<user-provided reason>
Expected: <result of describeTo>
but: <result of describeMismatch>
The user-provided reason can be defined by sending a String argument first into the assertThat()
method.
As you can see, the formatting of the output is such that a person doesn't need to include any extraneous information in the descriptions, such as whether it is describing a match or mismatch.
What's weird about these two methods is that they don't use String
s, per se. They use what's called a Description
object, which I'm pretty sure is what allows them to 1) avoid excessive String
concatenation, since it seems to work a bit like a StringBuilder
and 2) potentially use different kinds of Description
(which is an interface) objects to display things a little differently in different tools.
So, here's our implementation of those "describe" methods:
xxxxxxxxxx
override fun describe(description: Description) {
description.appendText("empty String")
}
override fun describeMismatch(item: Any?, description: Description) {
description.appendValue(item)
}
You'll note that the Description
object had the two methods appendText()
and appendValue()
. I've only ever used these two, even though it has a few more. Those are for a few rarer cases, and you should check them out if you're interested. If it wasn't clear, appendText
will take a String
and append it to the end of whatever's already in the description, just like a StringBuilder
. And appendValue
appears to run toString()
on whatever you pass in and append that.
Until Next Time
That's it! You're done. You now have a working Hamcrest Matcher. For any more information, you should check out the official website. If you were to do your research, though, you'd discover that we didn't recreate the built-in IsEmptyString
Matcher properly. There are a few techniques that are a little more advanced (and that would make this post get longer than I'm comfortable with). I will go over them in my next post.
Happy matching!
Further Reading
Published at DZone with permission of Jake Zimmerman, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments