Descriptive assertions in Kotlin for clearer tests
KotlinTestingStriktI’ve written already about mocks in Kotlin. In that post, I was using Atrium to write my assertions. Since then I gave Strikt a try, which is another cool little library. Meanwhile I was using AssertJ at work, so I’ve had the opportunity to experiment quite a bit lately!
There are two little tricks (KotlinTapas, if you will) that I find worth sharing:
- Assertions for data classes
- Custom assertions
Assertions for data classes
Our functions receive and return data classes. That means that our tests will often expect as a result a particular instance of one such class. For the assertions, we started by using isEqualTo
to compare the whole instance.
expectThat(SecurityContextHolder.getContext().authentication)
.isNotNull()
.isEqualTo(token)
this approach becomes a problem as your classes gain in complexity. Maybe they contain other entities, or there are lists or maps involved. Generating a proper instance to make isEqualTo
happy ends up being a lot of work.
Instead, we want to check just some of the properties. I prefer to avoid multiple assertions in one test, but in this case I see it as unavoidable. This is the solution I use for AssertJ, followed by the one for Strikt
// AssertJ
SoftAssertions.assertSoftly {
it.assertThat(token.name).isEqualTo("google-oauth2|3234123")
it.assertThat(token.authorities.map { it.authority }).contains("create:recipes")
}
// Strikt
expectThat(token) {
get { name }.isEqualTo("google-oauth2|3234123")
get { authorities.map { it.authority } }.contains("create:recipes")
}
I really like the compactness of the Strikt solution. To be fair, we could compress the AssertJ one with apply
. But I much prefer the second one.
What about the error message? A drawback of having different assertions is that you get an error message lacking in context:
Expecting:
<"EN">
to be equal to:
<"DE">
but was not.
Who can make sense of that without looking at the test in detail? Luckily, our solution offers a much more meaningful message:
org.opentest4j.AssertionFailedError: ▼ Expect that Some(TokenAuthentication@52789c41: Authenticated: true; Details: null; Granted Authorities: profile, create:recipes):
▼ TokenAuthentication@52789c41: Authenticated: true; Details: null; Granted Authorities: profile, create:recipes:
▼ name:
✗ is equal to "google-oauth2|3234123" : found "google-oauth2|dude"
Much better, isn’t it?
Custom assertions
A way of making assertions say more is to expand them according to our needs. For example, I have been playing with Arrow a lot lately (which on its own can be an endless source of blog posts I believe). I am getting away from using exceptions as much as I can, instead using the Either datatype. Or Monad, it’s not like I really know what I’m talking about.
In any case, I have a repository with a function that I want to test.
fun find(id: Int): Either<Int, RecipeDetails>
I’m calling the method, and want to assert that I got a valid return (Either.Right
). Then I want to check some of the properties of the output:
val recipe = repository.find(id)
expectThat(recipe)
.isA<Either.Right<RecipeDetails>>().get { b } and {
get { name }
.isEqualTo("carbonara")
get { ingredients.toList() }
.hasSize(3)
get { steps.toList() }
.hasSize(3)
}
this code is a bit unsatisfying. I have to check if the value is a Right
value, convert it, and then get the actual content before I can start asserting. Luckily for us, Strikt allows you to write custom assertions that are perfect for a case like this. After hitting my head against the typing for a while, I arrived at this helper:
private inline fun <reified T, reified U> Assertion.Builder<Either<U, T>>.isRight() =
isA<Either.Right<T>>()
.get { b }
which I use then like this:
val recipe = repository.find(id)
expectThat(recipe)
.isRight() and {
get { name }
.isEqualTo("carbonara")
get { ingredients.toList() }
.hasSize(3)
get { steps.toList() }
.hasSize(3)
}
not a huge change. However, it increases the readability of that little snippet and makes the intentions behind it clearer. I like code with good intentions.
The same can be done for the Option datatype:
inline fun <reified T> Assertion.Builder<Option<T>>.isSome() =
isA<Some<T>>()
.get { t }
inline fun <reified T> Assertion.Builder<Option<T>>.isEmpty() =
isA<None>()
Why, though?
What did we accomplish? Two things, in my mind:
- Tests will tell a better story of what is being tested and why.
- When they fail, it will be easier to figure out the reason.