Why does kotest use init blocks for its DSL?

I’m writing about DSLs in Kotlin, and I think I understand most of the DSL types pretty well, but kotest is puzzling to me. Obviously, you can write a test framework - be it stand alone or on top of JUnit - with “normal” classes.

Kotest uses init blocks instead, and while I tried to understand the code, it was quite convoluted and I gave up. So I wanted to ask: Why does kotest prefer init blocks, and how does the mechanism work on a very high level view? Or could you point me to an article explaining this DSL concept?

I’ve discussed this aspect with the author of kotest in the early days of the framework. There is no strong reason for this approach besides looking a bit more concise. The early versions of the framework (when it was named “Kotlin Test”) followed a more conventional approach.

Thank you, that was very helpful!

As compared to what? If you mean compared to JUnit-style annotated functions, it’s because it allows declaring tests in loops.

I’m not quite getting what you mean, could you please explain or give an example?

class DivisionTests : StringSpec({
    "Dividing by zero throws" {
        shouldThrow<IllegalArgumentException> { 5 / 0 }
    }

    val examples = listOf(1, -1, 5, Int.MAX_VALUE, Int.MAX_VALUE - 1, Int.MIN_VALUE)
    for (numerator in examples) {
        for (denominator in examples) {
            "Dividing by a non-zero number doesn't throw ($numerator/$denominator)" {
                println("Result: ${numerator / denominator}")
            }
        }
    }
})

Testing many variants of the same feature is trivial with Kotest. Writing this with an annotation-based frameworks would be either impossible, or quite unreadable.

In general, Kotlin libraries tend to favor DSLs to annotations. Everything is code, which makes complicated things much easier to work with.

1 Like

So you don’t need something “special” like JUnit’s @ParameterizedTest. Thank you very much, that makes a lot of sense now.

JUnit 5:

@ParameterizedTest
@ValueSource(ints = [1, -1, 5, Int.MAX_VALUE, Int.MAX_VALUE - 1, Int.MIN_VALUE])
fun `dividing by a non-zero number doesn't throw`(denominator: Int) {
    ...
}

To save the honor of JUnit: You can define tests dynamically with test factories.

It looks like this in practice:

@TestFactory
fun dynamicTestsFromCollection(): Collection<DynamicTest> =
    listOf(
        dynamicTest("1st dynamic test") { assertTrue(isPalindrome("madam")) },
        dynamicTest("2nd dynamic test") { assertEquals(4, calculator.multiply(2, 2)) }
    )

The interesting thing here is dynamicTests which is a factory method to create tests. Very powerful and concise.