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.

Sentry mobile image

Improving mobile performance, from slow screens to app start time

Based on our experience working with thousands of mobile developer teams, we developed a mobile monitoring maturity curve.

Read more

Top comments (0)