Use custom Hamcrest Matchers to level-up your tests

Ideally automated tests should be predictable, isolated and precise, allowing you to find an issue quickly. If these conditions are met, you’ll never have to change a test unless to accommodate a changed requirement. This sounds great on paper, but in practice we often forget the isolated bit and start testing multiple things in a single test. If abused, we’ll end up with tests that need updating all the time, causing developer frustration and wasting time.

One tool that can help with isolation of tests is using matchers. As the name suggests, matchers allow you to match an object agains certain conditions.

Hamcrest is the most popular framework for providing and writing matchers. It helps with:

  • make tests more explicit and readable – assertThat(luckyNumber), is(7))
  • produces better and more precise error messages – java.lang.AssertionError: Expected: the lucky number should be <7> but: was <5>
  • make tests more isolated by matching only a subset of properties of an object –
    assertThat(student, hasAverageGradeAbove(3))

If you need a refresher on Hamcrest you can check THIS great tutorial that explains most of the default matchers. Just using all of them will significantly improve the quality of your tests. To push things even more though, you’ll end up writing custom matchers.

Custom Hamcrest matchers

All matchers extend the BaseMatcher class. In practice though you’d almost always extend one of the more useful subclasses available (listed below). Make sure you pay special attention to the error messages your custom matcher will produce, as they are the first thing you’ll see when a test fails. Having descriptive and precise error messages is the real power of matchers as it helps you identify the cause of the problem easily.

TypeSafeMatcher

The TypeSafeMatcher matches only objects of a given type and fails immediately if applied on a different type of object. It does the null-checking and type-casting for you. Here’s an example matcher that only works against objects of type String:

@Test
fun hasCorrectLength() {
    assertThat("matchers rock!", hasLength(10))
}

fun hasLength(expectedLength: Int) = object : TypeSafeMatcher<String>() {
    override fun describeTo(description: Description) {
        description.appendText("string of length").appendValue(expectedLength)
    }

    override fun matchesSafely(item: String): Boolean {
        return item.length == expectedLength
    }

    override fun describeMismatchSafely(item: String, mismatchDescription: Description) {
        mismatchDescription.appendText("was").appendValue(item.length)
    }
}

You need to implement just 3 methods:

  • describeTo() -> give a description of the expected value. In case of failure, this will form the EXPECTED bit of the failure message
  • matchesSafely() -> do the actual check here
  • describeMismatchSafely() -> describe why the given item didn’t match. This forms the ACTUAL bit of the failure message
  • The test above produces the following error:

    java.lang.AssertionError: 
    Expected: string of length<10>
         but: was<14>
    

    NOTE: Get familiar with the available methods of the Description class and always use the most appropriate one. The appendValue() directs our attention by putting <brackets> around the the values that matter most – the expected and actual values. This might not seem like a big difference for this simple example, but it gets way more important as your matchers (and thus the descriptions) get more complex.

    TypeSafeDiagnosingMatcher

    It’s very similar to the matcher above, except the methods matchesSafely() and describeMismatchSafely() are combined here. This is one of the most often used matchers, as it allows you to build great error descriptions when applying it on complex objects.

    @Test
    fun canStartCompany() {
        assertThat(Person("John", 16), canStartCompany())
    }
    
    fun canStartCompany() = object : TypeSafeDiagnosingMatcher<Person>() {
        override fun describeTo(description: Description) {
            description.appendText("person is eligible to vote")
        }
    
        override fun matchesSafely(person: MatchersTest.Person, mismatchDescription: Description): Boolean {
            val isOldEnough = person.age >= 18
            val hasAddress = person.address != null
    
            val bothConditionsFailed = !isOldEnough && !hasAddress
    
            if (!isOldEnough) {
                mismatchDescription.appendText("this person is just ").appendValue(person.age)
            }
    
            if (!hasAddress) {
                val connector = if (bothConditionsFailed) "and" else ""
                mismatchDescription.appendText("$connector this person does not have an address ")
            }
    
            return isOldEnough && hasAddress
        }
    }
    

    This is a bit verbose, but the different failure cases produce very explicit error messages:

    java.lang.AssertionError: 
    Expected: person is eligible to vote
         but: this person is just <16>
    
    java.lang.AssertionError: 
    Expected: person is eligible to vote
         but: this person does not have an address 
    
    java.lang.AssertionError: 
    Expected: person is eligible to vote
         but: this person is just <16> and this person does not have an address 
    

    FeatureMatcher

    It takes a while to get used to the syntax of this one, but it’s the most often used. It allows you to match a single feature (property) of an object against a sub-matcher. Here’s an example:

    @Test
    fun willVerifyUserAgeCorrectly() {
        assertThat(Person("John", 16), is(oldAtLeast(18)))
    }
    
    fun oldAtLeast(minAge: Int): FeatureMatcher<Person, Int> {
        return object : FeatureMatcher<Person, Int>(Matchers.greaterThanOrEqualTo(minAge), "of age at least", "person's age") {
            override fun featureValueOf(person: MatchersTest.Person): Int {
                return person.age
            }
        }
    }
    

    This matcher can be applied on an Int property of the Person class, as specified by the generic types FeatureMatcher<Person, Int>. This property is returned by the only method we need to implement – featureValueOf().

    The constructor of the matcher is a concise way of passing all information it needs. First is the check to perform, which is in the form of a sub-matcher (Matchers.greaterThanOrEqualTo() is part of the Hamcrest library). Next is a description of the check itself (think of it as the describeFeature() method of the matchers above). Last is the specific property name, which will become part of the failure message.

    Without a lot of work on our part, the matcher produces a very explicit error message:

    java.lang.AssertionError: 
    Expected: is of age at least a value equal to or greater than <18>
         but: person's age <16> was less than <18>
    

    Conclusion

    I hope you see the power of custom matchers by now. They can make your unit tests more explicit, isolated and easier to debug.

    Happy testing!

    NOTE: If you tried out these examples and haven’t seen the same error messages, please make sure you’ve used org.hamcrest.MatcherAssert.assertThat() in your tests and NOT org.junit.assertThat().

Submit a Comment

Your email address will not be published. Required fields are marked *