Enhancing Object Matching in Kotlin Component Tests: From Simple Assertions to Hamcrest Matchers
I was writing a component test in Kotlin for a scenario where we publish an event to a Kafka topic whenever we receive a request from an upstream system. It offered a good opportunity to learn how to write a custom matcher for comparing two different types of objects!
For demonstration purposes let's imagine I'm a middleman between a customer wanting a cake and a baker. The cucumber scenario looked a bit like this:
Scenario: Tell the baker that the customer wants some cake
Given the customer has a cake request
When the request is submitted
Then a cake requested event is published to the baker
At some point in my test, I wanted to make sure that certain fields in the outgoing cake requested event matched the ones received in the incoming cake request. Because I was intercepting the outgoing event using a Kafka listener, it was in JsonNode format.
So imagine this incoming CakeRequest
:
data class CakeRequest(
val requestId: String,
val flavour: String,
val colour: String,
val wantsSprinkles: Boolean,
)
And an outgoing JsonNode
that looks like this:
{
"flavour":"chocolate",
"colour":"red",
"wantsSprinkles":"true"
}
What is the best way to make sure they have the same values (minus the requestId
)?
Simple private method
I started with this simple assertion:
assertTrue { hasSameEssentialValues(cakeRequest, resultingEvent) }
private fun hasSameEssentialValues(cakeRequest: CakeRequest, resultingEvent: JsonNode) =
cakeRequest.flavour == resultingEvent["flavour"].asText()
&& cakeRequest.colour == resultingEvent["colour"].asText()
&& cakeRequest.wantsSprinkles == resultingEvent["wantsSprinkles"].asBoolean()
While this worked fine, there were a few downsides:
The assertion didn't read fantastically
It polluted my test file with ugly private methods
Most importantly, the truth about how to match fields between a CakeRequest and a JsonNode was being mixed in with everything else.
In steps a Hamcrest matcher!
By the power of Hamcrest, I created an assertion that looks like this:
assertThat(resultingEvent, hasSameEssentialValuesAs(cakeRequest))
And a matcher in a separate file that looked like this:
fun hasSameEssentialValuesAs(cakeRequest: CakeRequest) = CakeRequestMatcher(cakeRequest)
class CakeRequestMatcher(private val cakeRequest: CakeRequest) : BaseMatcher<JsonNode>() {
override fun describeTo(description: Description) {
description.appendText("The important fields must match")
}
override fun matches(resultingEvent: Any?): Boolean {
if (resultingEvent !is JsonNode)
return false
return cakeRequest.flavour == resultingEvent["flavour"].asText()
&& cakeRequest.colour == resultingEvent["colour"].asText()
&& cakeRequest.wantsSprinkles == resultingEvent["wantsSprinkles"].asBoolean()
}
}
This makes the assertion read brilliantly and follows the single responsibility principle much better.
But there are some downsides:
The mandatory implementation
describeTo()
- not useful in my situationmatches()
takes theAny?
type as its input, requiring manual type-checkingThe whole thing is complex - it will be harder to read and maintain
Final Takeaways: Reflections and Next Steps
While this was a good exercise where I learned a lot about matchers (and ended up writing this article), in my specific use case I ultimately decided to check the fields in a simple package-level function in its own file. This kept the responsibility in the correct place and made the whole thing less complex.
It means when someone else (or me in 6 months) reads this test, they'll take less time to understand it, and less time to modify it. Which surely is the ultimate goal when writing code, right?
Update:
5 minutes after I published this article I sent the link to my esteemed work colleague who I worked with on this problem, and he replied straight away with a solution that blows mine out of the water. It solves the problem of readability and allows you to abstract the comparison logic away from both the test and the CakeRequest object:
assertTrue { cakeRequest.hasSameEssentialValuesAs(resultingEvent) }
Kotlin has extension functions, duh!!