Kotest
Deep Knowledge: Use
mcp__documentation__fetch_docswith technology:kotest.
Why Kotest
| Feature | Kotest | JUnit 5 | Spek |
|---|---|---|---|
| Spec styles (BDD, FunSpec, StringSpec, etc.) | ✅ 9 styles | ❌ Single | ✅ |
Rich matchers (shouldBe, shouldContain, shouldThrow) | ✅ Built-in | ❌ Need AssertJ/Hamcrest | ✅ |
| Property-based testing | ✅ Native | ❌ External | ❌ |
| Data-driven tests | ✅ withData | ✅ @ParameterizedTest | ❌ |
Coroutine native (runTest via coroutineScope) | ✅ | ✅ Manual | ❌ |
| KMP support | ✅ Full | Partial (JVM only) | ❌ |
| Lifecycle hooks | ✅ Many | ✅ | ✅ |
| Test isolation modes | ✅ Configurable | ❌ Per-method | ❌ |
| Plugin ecosystem | ✅ Spring, Allure, Koin, MockK | ✅ | Limited |
Setup
// build.gradle.kts
plugins {
id("io.kotest.multiplatform") version "5.9.1" // for KMP
// OR
kotlin("jvm") // JVM-only
}
dependencies {
testImplementation("io.kotest:kotest-runner-junit5:5.9.1") // JVM
testImplementation("io.kotest:kotest-assertions-core:5.9.1")
testImplementation("io.kotest:kotest-property:5.9.1") // property testing
testImplementation("io.kotest:kotest-framework-datatest:5.9.1") // data-driven
testImplementation("io.kotest.extensions:kotest-extensions-koin:1.3.0") // Koin plugin
}
// KMP (commonTest)
kotlin {
sourceSets.commonTest.dependencies {
implementation("io.kotest:kotest-framework-engine:5.9.1")
implementation("io.kotest:kotest-assertions-core:5.9.1")
implementation("io.kotest:kotest-property:5.9.1")
}
}
tasks.withType<Test> {
useJUnitPlatform() // for JVM Kotest runs via JUnit Platform
}
Spec Styles
StringSpec (most idiomatic)
class WalletTest : StringSpec({
"valid mnemonic creates wallet" {
val wallet = Wallet.fromMnemonic("abandon abandon ...")
wallet.address(0) shouldStartWith "bc1q"
}
"invalid mnemonic throws" {
shouldThrow<InvalidMnemonicException> {
Wallet.fromMnemonic("not enough words")
}
}
})
FunSpec
class WalletTest : FunSpec({
test("valid mnemonic creates wallet") {
val wallet = Wallet.fromMnemonic(...)
wallet.address(0) shouldStartWith "bc1q"
}
context("when balance is zero") {
test("send fails") {
shouldThrow<InsufficientFundsException> { wallet.send(...) }
}
}
})
BehaviorSpec (BDD)
class WalletBehavior : BehaviorSpec({
given("an empty wallet") {
val wallet = Wallet.empty()
`when`("sending 1000 sats") {
then("throws InsufficientFundsException") {
shouldThrow<InsufficientFundsException> { wallet.send(1000) }
}
}
}
given("a wallet with 5000 sats") {
val wallet = Wallet.withBalance(5000)
`when`("sending 1000 sats") {
val result = wallet.send(1000)
then("balance becomes 4000") { wallet.balance shouldBe 4000 }
then("returns success") { result.isSuccess shouldBe true }
}
}
})
DescribeSpec
class WalletDescribe : DescribeSpec({
describe("Wallet") {
describe("send()") {
it("succeeds when sufficient funds") { /* ... */ }
it("fails on insufficient funds") { /* ... */ }
}
}
})
FreeSpec (deeply nested)
class WalletFree : FreeSpec({
"Wallet" - {
"send" - {
"with sufficient funds" - {
"succeeds" {
val wallet = Wallet.withBalance(5000)
wallet.send(1000).isSuccess shouldBe true
}
}
}
}
})
Pick StringSpec for most cases. Use BehaviorSpec/DescribeSpec when stakeholders read tests. FunSpec for nested setup with context.
Matchers
// Equality
result shouldBe 42
result shouldNotBe 0
list shouldContainExactly listOf(1, 2, 3)
list shouldContainExactlyInAnyOrder listOf(3, 1, 2)
list shouldContain 5
list shouldHaveSize 3
// Strings
"hello world" shouldContain "world"
"abc" shouldStartWith "a"
"abc" shouldEndWith "c"
"abc" shouldMatch Regex("[a-z]+")
"abc" shouldHaveLength 3
// Collections
emptyList<Int>().shouldBeEmpty()
listOf(1, 2, 3).shouldContainAll(1, 2)
mapOf("a" to 1).shouldContainKey("a")
// Null
foo.shouldBeNull()
bar.shouldNotBeNull()
// Type
result.shouldBeInstanceOf<Success>()
result.shouldBeTypeOf<List<String>>()
// Booleans
flag.shouldBeTrue()
flag.shouldBeFalse()
// Numeric
balance shouldBeGreaterThan 0
balance shouldBeBetween 1000 to 5000
amount.toDouble() shouldBe (1.5 plusOrMinus 0.01)
// Throwable
shouldThrow<IllegalArgumentException> { invalid() }
val ex = shouldThrow<WalletException> { send() }
ex.message shouldContain "insufficient"
// Exact type
shouldThrowExactly<InsufficientFundsException> { /* not subclasses */ }
// Any matcher works in negative
result shouldNotBe null
"abc" shouldNotContain "xyz"
Inverse via shouldNot:
list shouldNot contain(0)
"abc" shouldNot startWith("z")
Data-Driven Tests (withData)
class UriParserTest : FunSpec({
context("BIP21 parser") {
withData(
"bitcoin:bc1q..." to BitcoinUri(address = "bc1q..."),
"bitcoin:bc1q...?amount=0.1" to BitcoinUri(address = "bc1q...", amount = 10_000_000),
"bitcoin:bc1q...?label=Test" to BitcoinUri(address = "bc1q...", label = "Test"),
) { (input, expected) ->
BitcoinUri.parse(input) shouldBe expected
}
}
})
For named cases:
withData(
nameFn = { "parses ${it.first} → ${it.second}" },
"bitcoin:bc1q..." to BitcoinUri(...),
"bitcoin:bc1q...?amount=0.1" to BitcoinUri(...),
) { (input, expected) ->
BitcoinUri.parse(input) shouldBe expected
}
For test class data:
data class TestCase(val input: String, val expected: BitcoinUri)
withData(
TestCase("bitcoin:bc1q...", BitcoinUri(...)),
TestCase("bitcoin:bc1q...?amount=0.1", BitcoinUri(...)),
) { (input, expected) ->
BitcoinUri.parse(input) shouldBe expected
}
Property-Based Testing
import io.kotest.property.*
import io.kotest.property.arbitrary.*
class FeeCalculatorTest : StringSpec({
"fee is always at least 1 sat/vbyte" {
forAll<Int>(Arb.int(0..1_000_000)) { weight ->
val fee = calculateFee(weight, feeRate = 1)
fee >= weight / 4
}
}
"fee scales linearly" {
checkAll<Int, Int>(
Arb.int(100..10_000), // weight
Arb.int(1..100), // fee rate
) { weight, rate ->
val fee = calculateFee(weight, rate)
fee shouldBe (weight / 4 * rate)
}
}
})
Arbitraries
Arb.int(0..100)
Arb.long()
Arb.double(0.0..1.0)
Arb.string(minSize = 1, maxSize = 100)
Arb.string(8..12, Codepoint.alphanumeric())
Arb.list(Arb.int(), 0..10)
Arb.set(Arb.string())
Arb.map(Arb.string(), Arb.int(), 0..10)
// Custom
data class User(val id: Long, val name: String)
val userArb = arbitrary {
User(
id = Arb.long().bind(),
name = Arb.string(1..50).bind(),
)
}
forAll<User>(userArb) { user ->
user.id != 0L
}
Shrinking
When a property fails, Kotest shrinks the input to find the smallest failing case automatically. Reported in test output.
Lifecycle Hooks
class WalletTest : StringSpec({
beforeSpec {
// Once before all tests in spec
Db.migrate()
}
beforeTest {
// Before each test
Db.clear()
}
afterTest { (test, result) ->
// After each test
if (result.isError) takeDebugSnapshot(test.name.testName)
}
afterSpec {
Db.close()
}
"test 1" { /* ... */ }
"test 2" { /* ... */ }
})
// Or via extension classes
class DatabaseExtension : BeforeSpecListener, AfterSpecListener {
override suspend fun beforeSpec(spec: Spec) { /* ... */ }
override suspend fun afterSpec(spec: Spec) { /* ... */ }
}
class WalletTest : StringSpec({
extension(DatabaseExtension())
"test" { /* ... */ }
})
Coroutine Support
Kotest test bodies are suspend — call suspending functions directly:
class WalletApiTest : StringSpec({
"fetches user" {
val api = WalletApi(mockClient())
val user = api.getUser(1) // suspend call OK
user.id shouldBe 1
}
})
For virtual time / kotlinx-coroutines-test integration:
"timeout cancels operation" {
runTest {
val result = withTimeoutOrNull(5_000) {
slowOperation()
}
result.shouldBeNull()
}
}
Test Isolation Modes
class WalletTest : StringSpec({
isolationMode = IsolationMode.InstancePerTest // new spec instance per test (slowest, most isolated)
// OR
isolationMode = IsolationMode.InstancePerLeaf // per leaf test
// OR (default)
isolationMode = IsolationMode.SingleInstance // shared spec instance (fastest)
"test 1" { /* ... */ }
"test 2" { /* ... */ }
})
Use InstancePerTest when tests mutate spec-level state.
Tags & Filtering
object Slow : Tag()
object Smoke : Tag()
class WalletTest : StringSpec({
"fast test" { /* ... */ }
"slow integration test".config(tags = setOf(Slow)) {
/* ... */
}
})
# Run only smoke tests
./gradlew test -Dkotest.tags="Smoke"
# Exclude slow
./gradlew test -Dkotest.tags="!Slow"
Configuration
src/test/kotlin/ProjectConfig.kt:
object ProjectConfig : AbstractProjectConfig() {
override val parallelism = 4
override val isolationMode = IsolationMode.InstancePerLeaf
override val timeout: Duration = 30.seconds
override suspend fun beforeProject() {
// run once before all tests in project
}
override fun extensions() = listOf(MyGlobalExtension())
}
Mocking — MockK Integration
Kotest doesn't bundle a mocking library — use MockK (the Kotlin-native mock library):
testImplementation("io.mockk:mockk:1.13.13")
class WalletServiceTest : StringSpec({
"fetches from cache when available" {
val cache = mockk<Cache> {
every { get(1L) } returns Wallet(1, "cached")
}
val api = mockk<Api> {
coEvery { getWallet(any()) } returns Wallet(1, "remote")
}
val service = WalletService(cache, api)
val result = service.getWallet(1L)
result.name shouldBe "cached"
verify(exactly = 0) { runBlocking { api.getWallet(any()) } }
}
})
KMP Specifics
Kotest works in commonTest:
// commonTest/kotlin/WalletTest.kt
class WalletTest : StringSpec({
"valid mnemonic" {
Wallet.fromMnemonic("abandon ...").address(0) shouldStartWith "bc1q"
}
})
Per-target test code goes in androidUnitTest, iosTest, desktopTest.
./gradlew :shared:jvmTest
./gradlew :shared:iosSimulatorArm64Test
./gradlew :shared:desktopTest
Spring Integration (kotest-extensions-spring)
For Spring Boot tests:
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.3.0")
@SpringBootTest
class UserServiceTest(private val service: UserService) : StringSpec() {
override fun extensions() = listOf(SpringExtension)
init {
"fetches user" {
service.findById(1)?.name shouldBe "Alice"
}
}
}
Test Containers + Kotest
testImplementation("io.kotest.extensions:kotest-extensions-testcontainers:2.0.2")
class IntegrationTest : StringSpec({
val postgres = install(JdbcDatabaseContainerExtension(PostgreSQLContainer("postgres:16")))
"queries database" {
val conn = postgres.createConnection("")
val rs = conn.createStatement().executeQuery("SELECT 1")
rs.next() shouldBe true
rs.getInt(1) shouldBe 1
}
})
Anti-Patterns
| Anti-pattern | Why it's bad | Correct approach |
|---|---|---|
| Mixing JUnit assertions with Kotest matchers | Inconsistent output | Use shouldBe everywhere |
@Test annotation on Kotest spec methods | Wrong runner | Don't use @Test — Kotest discovers via spec class |
class WalletTest : StringSpec() (no init {}) | Tests don't register | Use : StringSpec({ ... }) constructor block |
Holding mutable state across tests in SingleInstance mode | Flaky | Use InstancePerTest or move state inside test |
| Property test with weak assertions (always true) | Doesn't catch bugs | Make assertion meaningful (e.g., round-trip equality) |
runBlocking { } in test body | Wastes Kotest's coroutine support | Just call suspend directly |
Skipping useJUnitPlatform() in Gradle | Tests not discovered | Always set in tasks.withType<Test> |
Forgetting extensions() for Spring/Koin | Context not loaded | Override extensions() or use extension(...) in spec |
should fail with try/catch | Verbose | Use shouldThrow<T> |
Hardcoded delays (Thread.sleep) | Flaky | Use eventually or coroutine-based wait |
Eventually (Polling)
"value eventually becomes ready" {
eventually(5.seconds) {
getStatus() shouldBe "ready"
}
}
Configurable interval, factor, max attempts.
Test Execution Tips
# Run specific spec
./gradlew test --tests "com.bhodl.WalletTest"
# Run by name pattern
./gradlew test --tests "*WalletTest*"
# Specific test
./gradlew test --tests "com.bhodl.WalletTest" -Dkotest.test.name="fetches user"
For VS Code / IntelliJ: install Kotest plugin to run individual specs/tests from gutter.
When NOT to Use This Skill
| Scenario | Use Instead |
|---|---|
| Pure JUnit 5 patterns | Framework-specific (Spring Boot Test, etc.) |
| Flow assertion | testing/turbine |
| Compose UI tests | Compose Test or testing/compose-snapshot |
| Mobile E2E | testing/maestro |
| Mocking patterns deep dive | MockK docs |