Kotest — flexible, idiomatic Kotlin testing framework. Multiple specification styles (StringSpec, FunSpec, BehaviorSpec, DescribeSpec, FeatureSpec, FreeSpec), rich matcher library, property-based testing, data-driven tests, coroutine support, KMP-friendly. Drop-in alternative or complement to JUnit. USE WHEN: user mentions "Kotest", "io.kotest", "shouldBe", "StringSpec", "BehaviorSpec", "DescribeSpec", "kotest property testing", "Arb.list", "forAll", "kotest matchers", "kotlin tests" DO NOT USE FOR: JUnit-specific patterns - use junit skill (or framework-specific test skills) DO NOT USE FOR: Flow testing - use `testing/turbine` DO NOT USE FOR: Compose snapshot tests - use `testing/compose-snapshot` DO NOT USE FOR: Mobile E2E - use `testing/maestro`

GitHub
Install command
npx skhub add claude-dev-suite/kotest
Markdown
SKILL.md

Kotest

Deep Knowledge: Use mcp__documentation__fetch_docs with technology: kotest.

Why Kotest

FeatureKotestJUnit 5Spek
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✅ FullPartial (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-patternWhy it's badCorrect approach
Mixing JUnit assertions with Kotest matchersInconsistent outputUse shouldBe everywhere
@Test annotation on Kotest spec methodsWrong runnerDon't use @Test — Kotest discovers via spec class
class WalletTest : StringSpec() (no init {})Tests don't registerUse : StringSpec({ ... }) constructor block
Holding mutable state across tests in SingleInstance modeFlakyUse InstancePerTest or move state inside test
Property test with weak assertions (always true)Doesn't catch bugsMake assertion meaningful (e.g., round-trip equality)
runBlocking { } in test bodyWastes Kotest's coroutine supportJust call suspend directly
Skipping useJUnitPlatform() in GradleTests not discoveredAlways set in tasks.withType<Test>
Forgetting extensions() for Spring/KoinContext not loadedOverride extensions() or use extension(...) in spec
should fail with try/catchVerboseUse shouldThrow<T>
Hardcoded delays (Thread.sleep)FlakyUse 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

ScenarioUse Instead
Pure JUnit 5 patternsFramework-specific (Spring Boot Test, etc.)
Flow assertiontesting/turbine
Compose UI testsCompose Test or testing/compose-snapshot
Mobile E2Etesting/maestro
Mocking patterns deep diveMockK docs
Discovery
Tags

No tags published for this skill.

Version
Latest version metadata

Version

v2026.09.24

Published

Sep 24, 2026

Category

Uncategorized

License

MIT

Source path

skills/testing/kotest

Default branch

main

Latest commit

9496306

Tree SHA

fe4e2f1