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::classException 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 withDatafor data-driven tests, built-in property testing withcheckAllassertSoftlyreports all failures at once- Coroutines work natively — no
runBlockingwrapper needed - Runs on the JUnit Platform — no special Gradle configuration