Kotlin 测试模式
遵循 TDD 方法论,使用 Kotest 和 MockK 编写可靠、可维护测试的全面 Kotlin 测试模式。
何时使用
- 编写新的 Kotlin 函数或类
- 为现有 Kotlin 代码添加测试覆盖率
- 实现基于属性的测试
- 在 Kotlin 项目中遵循 TDD 工作流
- 为代码覆盖率配置 Kover
工作原理
- 确定目标代码 — 找到要测试的函数、类或模块
- 编写 Kotest 规范 — 选择与测试范围匹配的规范样式(StringSpec、FunSpec、BehaviorSpec)
- 模拟依赖项 — 使用 MockK 来隔离被测单元
- 运行测试(红色阶段) — 验证测试是否按预期失败
- 实现代码(绿色阶段) — 编写最少的代码以使测试通过
- 重构 — 改进实现,同时保持测试通过
- 检查覆盖率 — 运行
./gradlew koverHtmlReport并验证 80%+ 的覆盖率
示例
以下部分包含每个测试模式的详细、可运行示例:
快速参考
- Kotest 规范 — Kotest 规范样式 中的 StringSpec、FunSpec、BehaviorSpec、DescribeSpec 示例
- 模拟 — MockK 中的 MockK 设置、协程模拟、参数捕获
- TDD 演练 — Kotlin 的 TDD 工作流 中 EmailValidator 的完整 RED/GREEN/REFACTOR 周期
- 覆盖率 — Kover 覆盖率 中的 Kover 配置和命令
- Ktor 测试 — Ktor testApplication 测试 中的 testApplication 设置
Kotlin 的 TDD 工作流
RED-GREEN-REFACTOR 周期
RED -> 首先编写一个失败的测试GREEN -> 编写最少的代码使测试通过REFACTOR -> 改进代码同时保持测试通过REPEAT -> 继续下一个需求Kotlin 中逐步进行 TDD
kotlin
// Step 1: Define the interface/signature// EmailValidator.ktpackage com.example.validator
fun validateEmail(email: String): Result<String> { TODO("not implemented")}
// Step 2: Write failing test (RED)// EmailValidatorTest.ktpackage com.example.validator
import io.kotest.core.spec.style.StringSpecimport io.kotest.matchers.result.shouldBeFailureimport io.kotest.matchers.result.shouldBeSuccess
class EmailValidatorTest : StringSpec({ "valid email returns success" { validateEmail("[email protected]").shouldBeSuccess("[email protected]") }
"empty email returns failure" { validateEmail("").shouldBeFailure() }
"email without @ returns failure" { validateEmail("userexample.com").shouldBeFailure() }})
// Step 3: Run tests - verify FAIL// $ ./gradlew test// EmailValidatorTest > valid email returns success FAILED// kotlin.NotImplementedError: An operation is not implemented
// Step 4: Implement minimal code (GREEN)fun validateEmail(email: String): Result<String> { if (email.isBlank()) return Result.failure(IllegalArgumentException("Email cannot be blank")) if ('@' !in email) return Result.failure(IllegalArgumentException("Email must contain @")) val regex = Regex("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$") if (!regex.matches(email)) return Result.failure(IllegalArgumentException("Invalid email format")) return Result.success(email)}
// Step 5: Run tests - verify PASS// $ ./gradlew test// EmailValidatorTest > valid email returns success PASSED// EmailValidatorTest > empty email returns failure PASSED// EmailValidatorTest > email without @ returns failure PASSED
// Step 6: Refactor if needed, verify tests still passKotest 规范样式
StringSpec(最简单)
kotlin
class CalculatorTest : StringSpec({ "add two positive numbers" { Calculator.add(2, 3) shouldBe 5 }
"add negative numbers" { Calculator.add(-1, -2) shouldBe -3 }
"add zero" { Calculator.add(0, 5) shouldBe 5 }})FunSpec(类似 JUnit)
kotlin
class UserServiceTest : FunSpec({ val repository = mockk<UserRepository>() val service = UserService(repository)
test("getUser returns user when found") { val expected = User(id = "1", name = "Alice") coEvery { repository.findById("1") } returns expected
val result = service.getUser("1")
result shouldBe expected }
test("getUser throws when not found") { coEvery { repository.findById("999") } returns null
shouldThrow<UserNotFoundException> { service.getUser("999") } }})BehaviorSpec(BDD 风格)
kotlin
class OrderServiceTest : BehaviorSpec({ val repository = mockk<OrderRepository>() val paymentService = mockk<PaymentService>() val service = OrderService(repository, paymentService)
Given("a valid order request") { val request = CreateOrderRequest( userId = "user-1", items = listOf(OrderItem("product-1", quantity = 2)), )
When("the order is placed") { coEvery { paymentService.charge(any()) } returns PaymentResult.Success coEvery { repository.save(any()) } answers { firstArg() }
val result = service.placeOrder(request)
Then("it should return a confirmed order") { result.status shouldBe OrderStatus.CONFIRMED }
Then("it should charge payment") { coVerify(exactly = 1) { paymentService.charge(any()) } } }
When("payment fails") { coEvery { paymentService.charge(any()) } returns PaymentResult.Declined
Then("it should throw PaymentException") { shouldThrow<PaymentException> { service.placeOrder(request) } } } }})DescribeSpec(RSpec 风格)
kotlin
class UserValidatorTest : DescribeSpec({ describe("validateUser") { val validator = UserValidator()
context("with valid input") { it("accepts a normal user") { val user = CreateUserRequest("Alice", "[email protected]") validator.validate(user).shouldBeValid() } }
context("with invalid name") { it("rejects blank name") { val user = CreateUserRequest("", "[email protected]") validator.validate(user).shouldBeInvalid() }
it("rejects name exceeding max length") { val user = CreateUserRequest("A".repeat(256), "[email protected]") validator.validate(user).shouldBeInvalid() } } }})Kotest 匹配器
核心匹配器
kotlin
import io.kotest.matchers.shouldBeimport io.kotest.matchers.shouldNotBeimport io.kotest.matchers.string.*import io.kotest.matchers.collections.*import io.kotest.matchers.nulls.*
// Equalityresult shouldBe expectedresult shouldNotBe unexpected
// Stringsname shouldStartWith "Al"name shouldEndWith "ice"name shouldContain "lic"name shouldMatch Regex("[A-Z][a-z]+")name.shouldBeBlank()
// Collectionslist shouldContain "item"list shouldHaveSize 3list.shouldBeSorted()list.shouldContainAll("a", "b", "c")list.shouldBeEmpty()
// Nullsresult.shouldNotBeNull()result.shouldBeNull()
// Typesresult.shouldBeInstanceOf<User>()
// Numberscount shouldBeGreaterThan 0price shouldBeInRange 1.0..100.0
// ExceptionsshouldThrow<IllegalArgumentException> { validateAge(-1)}.message shouldBe "Age must be positive"
shouldNotThrow<Exception> { validateAge(25)}自定义匹配器
kotlin
fun beActiveUser() = object : Matcher<User> { override fun test(value: User) = MatcherResult( value.isActive && value.lastLogin != null, { "User ${value.id} should be active with a last login" }, { "User ${value.id} should not be active" }, )}
// Usageuser should beActiveUser()MockK
基本模拟
kotlin
class UserServiceTest : FunSpec({ val repository = mockk<UserRepository>() val logger = mockk<Logger>(relaxed = true) // Relaxed: returns defaults val service = UserService(repository, logger)
beforeTest { clearMocks(repository, logger) }
test("findUser delegates to repository") { val expected = User(id = "1", name = "Alice") every { repository.findById("1") } returns expected
val result = service.findUser("1")
result shouldBe expected verify(exactly = 1) { repository.findById("1") } }
test("findUser returns null for unknown id") { every { repository.findById(any()) } returns null
val result = service.findUser("unknown")
result.shouldBeNull() }})协程模拟
kotlin
class AsyncUserServiceTest : FunSpec({ val repository = mockk<UserRepository>() val service = UserService(repository)
test("getUser suspending function") { coEvery { repository.findById("1") } returns User(id = "1", name = "Alice")
val result = service.getUser("1")
result.name shouldBe "Alice" coVerify { repository.findById("1") } }
test("getUser with delay") { coEvery { repository.findById("1") } coAnswers { delay(100) // Simulate async work User(id = "1", name = "Alice") }
val result = service.getUser("1") result.name shouldBe "Alice" }})参数捕获
kotlin
test("save captures the user argument") { val slot = slot<User>() coEvery { repository.save(capture(slot)) } returns Unit
service.createUser(CreateUserRequest("Alice", "[email protected]"))
slot.captured.name shouldBe "Alice" slot.captured.email shouldBe "[email protected]" slot.captured.id.shouldNotBeNull()}间谍和部分模拟
kotlin
test("spy on real object") { val realService = UserService(repository) val spy = spyk(realService)
every { spy.generateId() } returns "fixed-id"
spy.createUser(request)
verify { spy.generateId() } // Overridden // Other methods use real implementation}协程测试
用于挂起函数的 runTest
kotlin
import kotlinx.coroutines.test.runTest
class CoroutineServiceTest : FunSpec({ test("concurrent fetches complete together") { runTest { val service = DataService(testScope = this)
val result = service.fetchAllData()
result.users.shouldNotBeEmpty() result.products.shouldNotBeEmpty() } }
test("timeout after delay") { runTest { val service = SlowService()
shouldThrow<TimeoutCancellationException> { withTimeout(100) { service.slowOperation() // Takes > 100ms } } } }})测试 Flow
kotlin
import io.kotest.matchers.collections.shouldContainInOrderimport kotlinx.coroutines.flow.MutableSharedFlowimport kotlinx.coroutines.flow.toListimport kotlinx.coroutines.launchimport kotlinx.coroutines.test.advanceTimeByimport kotlinx.coroutines.test.runTest
class FlowServiceTest : FunSpec({ test("observeUsers emits updates") { runTest { val service = UserFlowService()
val emissions = service.observeUsers() .take(3) .toList()
emissions shouldHaveSize 3 emissions.last().shouldNotBeEmpty() } }
test("searchUsers debounces input") { runTest { val service = SearchService() val queries = MutableSharedFlow<String>()
val results = mutableListOf<List<User>>() val job = launch { service.searchUsers(queries).collect { results.add(it) } }
queries.emit("a") queries.emit("ab") queries.emit("abc") // Only this should trigger search advanceTimeBy(500)
results shouldHaveSize 1 job.cancel() } }})TestDispatcher
kotlin
import kotlinx.coroutines.test.StandardTestDispatcherimport kotlinx.coroutines.test.advanceUntilIdle
class DispatcherTest : FunSpec({ test("uses test dispatcher for controlled execution") { val dispatcher = StandardTestDispatcher()
runTest(dispatcher) { var completed = false
launch { delay(1000) completed = true }
completed shouldBe false advanceTimeBy(1000) completed shouldBe true } }})基于属性的测试
Kotest 属性测试
kotlin
import io.kotest.core.spec.style.FunSpecimport io.kotest.property.Arbimport io.kotest.property.arbitrary.*import io.kotest.property.forAllimport io.kotest.property.checkAllimport kotlinx.serialization.json.Jsonimport kotlinx.serialization.encodeToStringimport kotlinx.serialization.decodeFromString
// Note: The serialization roundtrip test below requires the User data class// to be annotated with @Serializable (from kotlinx.serialization).
class PropertyTest : FunSpec({ test("string reverse is involutory") { forAll<String> { s -> s.reversed().reversed() == s } }
test("list sort is idempotent") { forAll(Arb.list(Arb.int())) { list -> list.sorted() == list.sorted().sorted() } }
test("serialization roundtrip preserves data") { checkAll(Arb.bind(Arb.string(1..50), Arb.string(5..100)) { name, email -> User(name = name, email = "[email protected]") }) { user -> val json = Json.encodeToString(user) val decoded = Json.decodeFromString<User>(json) decoded shouldBe user } }})自定义生成器
kotlin
val userArb: Arb<User> = Arb.bind( Arb.string(minSize = 1, maxSize = 50), Arb.email(), Arb.enum<Role>(),) { name, email, role -> User( id = UserId(UUID.randomUUID().toString()), name = name, email = Email(email), role = role, )}
val moneyArb: Arb<Money> = Arb.bind( Arb.long(1L..1_000_000L), Arb.enum<Currency>(),) { amount, currency -> Money(amount, currency)}数据驱动测试
Kotest 中的 withData
kotlin
class ParserTest : FunSpec({ context("parsing valid dates") { withData( "2026-01-15" to LocalDate(2026, 1, 15), "2026-12-31" to LocalDate(2026, 12, 31), "2000-01-01" to LocalDate(2000, 1, 1), ) { (input, expected) -> parseDate(input) shouldBe expected } }
context("rejecting invalid dates") { withData( nameFn = { "rejects '$it'" }, "not-a-date", "2026-13-01", "2026-00-15", "", ) { input -> shouldThrow<DateParseException> { parseDate(input) } } }})测试生命周期和固件
BeforeTest / AfterTest
kotlin
class DatabaseTest : FunSpec({ lateinit var db: Database
beforeSpec { db = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1") transaction(db) { SchemaUtils.create(UsersTable) } }
afterSpec { transaction(db) { SchemaUtils.drop(UsersTable) } }
beforeTest { transaction(db) { UsersTable.deleteAll() } }
test("insert and retrieve user") { transaction(db) { UsersTable.insert { it[name] = "Alice" it[email] = "[email protected]" } }
val users = transaction(db) { UsersTable.selectAll().map { it[UsersTable.name] } }
users shouldContain "Alice" }})Kotest 扩展
kotlin
// Reusable test extensionclass DatabaseExtension : BeforeSpecListener, AfterSpecListener { lateinit var db: Database
override suspend fun beforeSpec(spec: Spec) { db = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1") }
override suspend fun afterSpec(spec: Spec) { // cleanup }}
class UserRepositoryTest : FunSpec({ val dbExt = DatabaseExtension() register(dbExt)
test("save and find user") { val repo = UserRepository(dbExt.db) // ... }})Kover 覆盖率
Gradle 配置
kotlin
// build.gradle.ktsplugins { id("org.jetbrains.kotlinx.kover") version "0.9.7"}
kover { reports { total { html { onCheck = true } xml { onCheck = true } } filters { excludes { classes("*.generated.*", "*.config.*") } } verify { rule { minBound(80) // Fail build below 80% coverage } } }}覆盖率命令
bash
# Run tests with coverage./gradlew koverHtmlReport
# Verify coverage thresholds./gradlew koverVerify
# XML report for CI./gradlew koverXmlReport
# View HTML report (use the command for your OS)# macOS: open build/reports/kover/html/index.html# Linux: xdg-open build/reports/kover/html/index.html# Windows: start build/reports/kover/html/index.html覆盖率目标
| 代码类型 | 目标 |
|---|---|
| 关键业务逻辑 | 100% |
| 公共 API | 90%+ |
| 通用代码 | 80%+ |
| 生成的 / 配置代码 | 排除 |
Ktor testApplication 测试
kotlin
class ApiRoutesTest : FunSpec({ test("GET /users returns list") { testApplication { application { configureRouting() configureSerialization() }
val response = client.get("/users")
response.status shouldBe HttpStatusCode.OK val users = response.body<List<UserResponse>>() users.shouldNotBeEmpty() } }
test("POST /users creates user") { testApplication { application { configureRouting() configureSerialization() }
val response = client.post("/users") { contentType(ContentType.Application.Json) setBody(CreateUserRequest("Alice", "[email protected]")) }
response.status shouldBe HttpStatusCode.Created } }})测试命令
bash
# Run all tests./gradlew test
# Run specific test class./gradlew test --tests "com.example.UserServiceTest"
# Run specific test./gradlew test --tests "com.example.UserServiceTest.getUser returns user when found"
# Run with verbose output./gradlew test --info
# Run with coverage./gradlew koverHtmlReport
# Run detekt (static analysis)./gradlew detekt
# Run ktlint (formatting check)./gradlew ktlintCheck
# Continuous testing./gradlew test --continuous最佳实践
应做:
- 先写测试(TDD)
- 在整个项目中一致地使用 Kotest 的规范样式
- 对挂起函数使用 MockK 的
coEvery/coVerify - 对协程测试使用
runTest - 测试行为,而非实现
- 对纯函数使用基于属性的测试
- 为清晰起见使用
data class测试固件
不应做:
- 混合使用测试框架(选择 Kotest 并坚持使用)
- 模拟数据类(使用真实实例)
- 在协程测试中使用
Thread.sleep()(改用advanceTimeBy) - 跳过 TDD 中的红色阶段
- 直接测试私有函数
- 忽略不稳定的测试
与 CI/CD 集成
yaml
# GitHub Actions exampletest: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: '21'
- name: Run tests with coverage run: ./gradlew test koverXmlReport
- name: Verify coverage run: ./gradlew koverVerify
- name: Upload coverage uses: codecov/codecov-action@v5 with: files: build/reports/kover/report.xml token: ${{ secrets.CODECOV_TOKEN }}记住:测试就是文档。它们展示了你的 Kotlin 代码应如何使用。使用 Kotest 富有表现力的匹配器使测试可读,并使用 MockK 来清晰地模拟依赖项。


