loading...

Kotlin sealed classes: how to test them with Truth

dbottillo profile image Daniele Bottillo ・1 min read

In my team at Deliveroo we've recently introduced Truth library for unit testing and we are very pleased with it: we like the syntax, the readability and the extendability.
For example when dealing with sealed classes, unit test are not really the best; let's assume to have a very simple sealed class:

sealed class Food {
    class Pizza(val slices: Int): Food()
    class Sushi(val pieces: Int): Food()
}

In a typical unit test, if you want to test that some food is pizza and have 4 slices, you could write something like this:

@Test
fun `food is pizza and have 4 slices`() {
    val underTest = getFood()

    assertThat(underTest is Food.Pizza).isTrue()
    assertThat((underTest as Food.Pizza).slices).isEqualTo(4)
}

That's not elegant! Using is and as makes the code ugly and less readable and until Kotlin Contracts are stable there is no much that we can do to improve unless we create a custom subject. The idea is to extend Truth to have a more fluent syntax:

assertThat(underTest).isPizza(slices = 4)

That would make more sense! Let's see how to create this custom assertion, Truth requires two components: a subject and a subject factory. The subject is where the assertion lives and the factory is used to expose your custom subject:

class FoodSubject(metadata: FailureMetadata, actual: Food) : Subject<FoodSubject, Food>(metadata, actual) {}
fun food(): Subject.Factory<FoodSubject, Food> {
    return Subject.Factory<FoodSubject, Food> { metaData, target -> FoodSubject(metaData, target) }
}

The constructor of the subject is where you receive the target of your test actual:Food and inside the subject you can define your own assertion:

fun isPizza(slices:Int) {
    if (actual() !is Food.Pizza) {
        failWithoutActual(Fact.simpleFact("expected to be a Pizza!"))
    } 
    val target = actual() as Food.Pizza
    if (target.slices != slices) {
        failWithActual(Fact.simpleFact("expected to be have '$slices' slices but have '${target.slices}' instead"))
    }
}

The actual() method returns the food test object on which you can check whatever you are interesting in, in this case the method is testing that the type is Pizza and that the number of slices are the same. To use this method from the unit test you can do this:

@Test
fun `food is pizza and have 4 slices`() {
    val underTest = generateFood()

    assertWithMessage("food should be pizza with 4 slices")
            .about(food())
            .that(underTest)
            .isPizza(slices = 4)
}

That's very close to what we wanted in the beginning! But there is still one small improvement that we can do it. The about(food()).that(underTest) can be extracted to an utility method in the subject definition:

fun assertThat(@Nullable food: Food): FoodSubject {
    return assertAbout(food()).that(food)
}

This will change the test to:

@Test
fun `food is pizza and have 4 slices`() {
    val underTest = generateFood()
    assertThat(underTest).isPizza(slices = 4)
}

Which is exactly what we want :)

You can find the full example of GitHub: https://github.com/dbottillo/Blog/tree/truth_assertion

Happy testing!

Discussion

pic
Editor guide