DEV Community

Mario Fernández
Mario Fernández

Posted on • Originally published at hceris.com on

Descriptive assertions in Kotlin for clearer tests

I’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)
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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>()
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)