Kotest Guide: Kotlin Testing Framework with Multiple Spec Styles

Kotest Guide: Kotlin Testing Framework with Multiple Spec Styles

Kotest is a pure-Kotlin testing framework that goes beyond JUnit 5's Java roots. It offers multiple specification styles, a rich matcher library, built-in property testing, and first-class coroutine support. If you want test code that reads like idiomatic Kotlin, Kotest is worth learning.

Installation

// build.gradle.kts
dependencies {
    testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
    testImplementation("io.kotest:kotest-assertions-core:5.9.1")
    testImplementation("io.kotest:kotest-property:5.9.1")  // optional: property testing
}

tasks.test {
    useJUnitPlatform()
}

Kotest runs on the JUnit Platform, so it works with Gradle and Maven without special configuration.

Spec Styles

Kotest's signature feature is multiple specification styles. Choose the one that fits your team's preference.

FunSpec — Simple and Explicit

import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe

class CalculatorTest : FunSpec({

    test("addition returns correct sum") {
        val result = Calculator().add(2, 3)
        result shouldBe 5
    }

    test("subtraction handles negatives") {
        Calculator().subtract(1, 5) shouldBe -4
    }
})

FunSpec is the closest to JUnit 5's @Test style.

DescribeSpec — RSpec / Jasmine Style

import io.kotest.core.spec.style.DescribeSpec

class UserServiceTest : DescribeSpec({

    describe("UserService") {
        val service = UserService()

        describe("createUser") {
            it("returns a user with the given name") {
                val user = service.createUser("Alice")
                user.name shouldBe "Alice"
            }

            it("assigns a non-zero id") {
                val user = service.createUser("Bob")
                user.id shouldNotBe 0
            }
        }

        describe("deleteUser") {
            it("throws when user does not exist") {
                shouldThrow<UserNotFoundException> {
                    service.deleteUser(999)
                }
            }
        }
    }
})

describe/it nesting produces structured reports that map to behavior descriptions.

BehaviorSpec — Given/When/Then

import io.kotest.core.spec.style.BehaviorSpec

class CheckoutTest : BehaviorSpec({

    given("a cart with items") {
        val cart = Cart()
        cart.add(Item("Widget", 9.99))

        `when`("checkout is called") {
            val order = cart.checkout()

            then("order total matches cart total") {
                order.total shouldBe cart.total()
            }

            then("cart is cleared") {
                cart.isEmpty() shouldBe true
            }
        }
    }
})

BehaviorSpec enforces Given/When/Then structure — useful for teams practicing BDD.

ShouldSpec — Concise

import io.kotest.core.spec.style.ShouldSpec

class EmailValidatorTest : ShouldSpec({

    context("valid emails") {
        should("accept standard format") {
            isValidEmail("user@example.com") shouldBe true
        }

        should("accept subdomains") {
            isValidEmail("user@mail.example.com") shouldBe true
        }
    }

    context("invalid emails") {
        should("reject missing @") {
            isValidEmail("notanemail") shouldBe false
        }
    }
})

Matchers

Kotest's matcher library is extensive and Kotlin-idiomatic:

Core Matchers

result shouldBe 42
result shouldNotBe 0
result shouldBeGreaterThan 10
result shouldBeLessThanOrEqual 100

str shouldContain "hello"
str shouldStartWith "Hello"
str shouldEndWith "world"
str shouldMatch Regex("[a-z]+")

list shouldHaveSize 3
list shouldContain "element"
list shouldContainAll listOf("a", "b")
list shouldBeEmpty()
list.shouldNotBeEmpty()

value.shouldBeNull()
value.shouldNotBeNull()
value shouldBeInstanceOf String::class

Exception Matchers

shouldThrow<IllegalArgumentException> {
    parseNegative(-1)
}

val ex = shouldThrow<IllegalStateException> {
    riskyOperation()
}
ex.message shouldContain "invalid state"

shouldNotThrowAny {
    safeOperation()
}

Soft Assertions

Run all assertions even when some fail:

assertSoftly(user) {
    name shouldBe "Alice"
    age shouldBe 30
    email shouldContain "@"
    isActive shouldBe true
}

All failures are reported together at the end.

Lifecycle Hooks

class ServiceTest : FunSpec({

    beforeSpec {
        // runs once before all tests in this spec
        database.connect()
    }

    afterSpec {
        database.disconnect()
    }

    beforeEach {
        database.beginTransaction()
    }

    afterEach {
        database.rollbackTransaction()
    }

    test("insert user") {
        val user = database.insert(User("Alice"))
        user.id shouldNotBe null
    }
})

Data-Driven Tests with withData

import io.kotest.core.spec.style.FunSpec
import io.kotest.datatest.withData

data class DivisionCase(val a: Int, val b: Int, val expected: Double)

class DivisionTest : FunSpec({

    withData(
        DivisionCase(10, 2, 5.0),
        DivisionCase(9, 3, 3.0),
        DivisionCase(7, 2, 3.5),
    ) { (a, b, expected) ->
        (a.toDouble() / b) shouldBe expected
    }
})

Each withData entry generates a separate test case with descriptive naming from the data class toString().

Property Testing

Kotest bundles property testing similar to QuickCheck:

import io.kotest.property.checkAll
import io.kotest.property.arbitrary.int

class StringReverseTest : FunSpec({

    test("reversing twice is identity") {
        checkAll<String> { s ->
            s.reversed().reversed() shouldBe s
        }
    }

    test("addition is commutative") {
        checkAll(Arb.int(-1000..1000), Arb.int(-1000..1000)) { a, b ->
            (a + b) shouldBe (b + a)
        }
    }
})

checkAll generates 1000 random cases by default. Failed cases are shrunk automatically.

Coroutine Support

Kotest tests run inside a coroutine context by default:

import io.kotest.core.spec.style.FunSpec
import kotlinx.coroutines.delay

class AsyncServiceTest : FunSpec({

    test("async operation completes") {
        val service = AsyncService()
        val result = service.fetchData()  // suspend fun
        result shouldNotBe null
    }

    test("concurrent operations") {
        val results = (1..10).map { id ->
            kotlinx.coroutines.async { service.getUser(id) }
        }.map { it.await() }

        results shouldHaveSize 10
    }
})

No runBlocking or runTest wrapper needed — Kotest handles coroutine context automatically.

Configuration

Global configuration via AbstractProjectConfig:

// src/test/kotlin/ProjectConfig.kt
import io.kotest.core.config.AbstractProjectConfig

object ProjectConfig : AbstractProjectConfig() {
    override val parallelism = 4
    override val testCaseOrder = TestCaseOrder.Random  // randomize order to catch dependencies
    override val invocationCount = 1
}

Kotest vs JUnit 5

Feature JUnit 5 Kotest
Spec styles One (class + method) 9 styles (FunSpec, BehaviorSpec, etc.)
Matchers assertEquals, assertTrue Rich DSL: shouldBe, shouldContain
Data-driven tests @ParameterizedTest withData
Property testing External (jqwik) Built-in
Coroutine support External (runTest) Built-in
Java interop Excellent Good

Monitoring Production Behavior

Kotest validates logic at build time against known inputs. For continuous behavioral testing of live production endpoints, HelpMeTest runs test scenarios 24/7 without requiring source code or a Kotlin toolchain.

Summary

  • Kotest offers 9 spec styles — choose based on team preference
  • Infix matcher syntax (shouldBe, shouldContain) is more readable than JUnit assertions
  • withData for data-driven tests, built-in property testing with checkAll
  • assertSoftly reports all failures at once
  • Coroutines work natively — no runBlocking wrapper needed
  • Runs on the JUnit Platform — no special Gradle configuration

Read more