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
安装命令
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
发现
标签

此技能尚未发布标签。

版本
最新版本元数据

版本

v2026.09.24

发布时间

2026年9月24日

分类

未分类

许可证

MIT

源路径

skills/testing/kotest

默认分支

main

最新提交

9496306

Tree SHA

fe4e2f1