Kotlin 测试模式
全面的 Kotlin 测试模式,用于编写遵循 TDD 方法论且使用 Kotest 和 MockK 的可靠、可维护的测试。
何时使用
- 编写新的 Kotlin 函数或类时
- 为现有 Kotlin 代码添加测试覆盖
- 实现基于属性的测试
- 在 Kotlin 项目中遵循 TDD 工作流
- 配置 Kover 进行代码覆盖率
工作原理
- 识别目标代码 — 找到要测试的函数、类或模块
- 编写 Kotest 规范 — 选择与测试范围匹配的规范风格(StringSpec、FunSpec、BehaviorSpec)
- 模拟依赖 — 使用 MockK 隔离被测单元
- 运行测试(RED) — 验证测试因预期错误而失败
- 实现代码(GREEN) — 编写最少的代码以通过测试
- 重构 — 改进实现同时保持测试通过
- 检查覆盖率 — 运行
./gradlew koverHtmlReport并验证 80% 以上的覆盖
示例
以下部分包含每种测试模式的详细、可运行示例:
快速参考
- Kotest 规范 — Kotest 规范风格 中的 StringSpec、FunSpec、BehaviorSpec、DescribeSpec 示例
- 模拟 — MockK 中的 MockK 设置、协程模拟、参数捕获
- TDD 演练 — Kotlin 的 TDD 工作流 中的完整 RED/GREEN/REFACTOR 周期(使用 EmailValidator)
- 覆盖率 — Kover 覆盖率 中的 Kover 配置和命令
- Ktor 测试 — Ktor testApplication 测试 中的 testApplication 设置
Kotlin 的 TDD 工作流
RED-GREEN-REFACTOR 周期
RED -> 先编写一个会失败的测试
GREEN -> 编写最少的代码以通过测试
REFACTOR -> 改进代码同时保持测试通过
REPEAT -> 继续下一个需求
Kotlin 中的逐步 TDD
// 第 1 步:定义接口/签名
// EmailValidator.kt
package com.example.validator
fun validateEmail(email: String): Result<String> {
TODO("not implemented")
}
// 第 2 步:编写会失败的测试(RED)
// EmailValidatorTest.kt
package com.example.validator
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.result.shouldBeFailure
import 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()
}
})
// 第 3 步:运行测试 - 验证失败
// $ ./gradlew test
// EmailValidatorTest > valid email returns success FAILED
// kotlin.NotImplementedError: An operation is not implemented
// 第 4 步:实现最少代码(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)
}
// 第 5 步:运行测试 - 验证通过
// $ ./gradlew test
// EmailValidatorTest > valid email returns success PASSED
// EmailValidatorTest > empty email returns failure PASSED
// EmailValidatorTest > email without @ returns failure PASSED
// 第 6 步:必要时重构,验证测试仍然通过
Kotest 规范风格
StringSpec(最简单)
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)
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 风格)
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 风格)
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 匹配器
核心匹配器
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.string.*
import io.kotest.matchers.collections.*
import io.kotest.matchers.nulls.*
// 相等性
result shouldBe expected
result shouldNotBe unexpected
// 字符串
name shouldStartWith "Al"
name shouldEndWith "ice"
name shouldContain "lic"
name shouldMatch Regex("[A-Z][a-z]+")
name.shouldBeBlank()
// 集合
list shouldContain "item"
list shouldHaveSize 3
list.shouldBeSorted()
list.shouldContainAll("a", "b", "c")
list.shouldBeEmpty()
// 空值
result.shouldNotBeNull()
result.shouldBeNull()
// 类型
result.shouldBeInstanceOf<User>()
// 数字
count shouldBeGreaterThan 0
price shouldBeInRange 1.0..100.0
// 异常
shouldThrow<IllegalArgumentException> {
validateAge(-1)
}.message shouldBe "Age must be positive"
shouldNotThrow<Exception> {
validateAge(25)
}
自定义匹配器
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" },
)
}
// 使用方式
user should beActiveUser()
MockK
基础模拟
class UserServiceTest : FunSpec({
val repository = mockk<UserRepository>()
val logger = mockk<Logger>(relaxed = true) // Relaxed: 返回默认值
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()
}
})
协程模拟
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) // 模拟异步工作
User(id = "1", name = "Alice")
}
val result = service.getUser("1")
result.name shouldBe "Alice"
}
})
参数捕获
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()
}
间谍和部分模拟
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() } // 被覆盖的方法
// 其他方法使用真实实现
}
协程测试
用于挂起函数的 runTest
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() // 耗时 > 100ms
}
}
}
}
})
测试 Flow
import io.kotest.matchers.collections.shouldContainInOrder
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import 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") // 只有这个应该触发搜索
advanceTimeBy(500)
results shouldHaveSize 1
job.cancel()
}
}
})
TestDispatcher
import kotlinx.coroutines.test.StandardTestDispatcher
import 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 属性测试
import io.kotest.core.spec.style.FunSpec
import io.kotest.property.Arb
import io.kotest.property.arbitrary.*
import io.kotest.property.forAll
import io.kotest.property.checkAll
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
// 注意:下面的序列化往返测试要求 User 数据类
// 使用 @Serializable 注解(来自 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@test.com")
}) { user ->
val json = Json.encodeToString(user)
val decoded = Json.decodeFromString<User>(json)
decoded shouldBe user
}
}
})
自定义生成器
import io.kotest.property.Arb
import io.kotest.property.arbitrary.*
// 用户自定义生成器
val Arb.Companion.user: Arb<User>
get() = arbitrary {
User(
id = it.nextUUID().toString(),
name = it.nextString(3..20, Codepoint.alphabetical()),
email = "${it.nextString(5..15, Codepoint.alphanumeric())}@${it.nextString(3..8, Codepoint.alphabetical())}.com",
age = it.nextUInt(18u..120u).toInt(),
)
}
// 使用方式
class UserGeneratorTest : FunSpec({
test("valid users pass validation") {
checkAll(Arb.user) { user ->
val result = UserValidator().validate(user)
result.shouldBeValid()
}
}
})
Kover 覆盖率
build.gradle.kts 配置
plugins {
id("org.jetbrains.kotlin.jvm") version "1.9.0"
id("org.jetbrains.kotlinx.kover") version "0.7.5"
}
koverReport {
defaults {
html {
onCheck = true
}
verify {
onCheck = true
rule {
minBound(80)
}
}
}
}
常用命令
# 生成 HTML 报告
./gradlew koverHtmlReport
# 生成 XML 报告(用于 CI)
./gradlew koverXmlReport
# 验证覆盖率是否满足最低阈值
./gradlew koverVerify
# 仅针对特定源的覆盖率
./gradlew koverHtmlReport --tests '*UserService*'
# 重置并重新生成覆盖率数据
./gradlew clean koverHtmlReport
排除某些文件
koverReport {
filters {
excludes {
classes(
"*Generated*",
"*MapperImpl*",
"**/model/**",
)
}
}
}
Ktor testApplication 测试
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
import io.kotest.core.spec.style.FunSpec
class UserRoutesTest : FunSpec({
test("GET /users returns list") {
testApplication {
application {
module() // 加载你的 Ktor 模块
}
val response = client.get("/users")
response.status shouldBe HttpStatusCode.OK
response.bodyAsText() shouldContain "["
}
}
test("POST /users creates user") {
testApplication {
application {
module()
}
val response = client.post("/users") {
contentType(ContentType.Application.Json)
setBody("""{"name": "Alice", "email": "[email protected]"}""")
}
response.status shouldBe HttpStatusCode.Created
}
}
})
Jetpack Compose 测试
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Rule
import org.junit.Test
class LoginScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun loginScreen_showsSubmitButton() {
composeTestRule.setContent {
LoginScreen()
}
composeTestRule
.onNodeWithText("登录")
.assertExists()
}
@Test
fun loginScreen_showsErrorOnEmptyEmail() {
composeTestRule.setContent {
LoginScreen()
}
composeTestRule.onNodeWithText("登录").performClick()
composeTestRule
.onNodeWithText("邮箱不能为空")
.assertIsDisplayed()
}
@Test
fun loginScreen_navigatesOnSuccess() {
composeTestRule.setContent {
LoginScreen(onLoginSuccess = { /* mock */ })
}
composeTestRule.onNodeWithTag("email-input").performTextInput("[email protected]")
composeTestRule.onNodeWithTag("password-input").performTextInput("password123")
composeTestRule.onNodeWithText("登录").performClick()
// 验证谓词被调用
// ...
}
}
常见陷阱
1. 协程范围不匹配
为每个测试创建新的范围,不要重复使用,以避免泄漏。
2. 模拟泄漏
始终在 beforeTest 中清理模拟:
beforeTest {
clearMocks(repository, analyticsService)
}
3. 测试之间的可变状态
将可变状态保留在测试类范围之外,以便每个测试都获得新的实例。
4. 忽略嵌套测试的异常
不要忘记在嵌套范围内使用 shouldThrow,因为它不会自动传播。
// 错误:异常未捕获
context("nested") {
someFunction() // 如果抛出异常,测试不会失败
}
// 正确
context("nested") {
shouldThrow<Exception> {
someFunction()
}
}
架构决策记录
| 决策 | 替代方案 | 理由 |
|---|---|---|
| Kotest > JUnit 5 | JUnit 5 + Hamcrest | 更好的 Kotlin 集成,更丰富的匹配器,内置属性测试 |
| MockK > Mockito | Mockito + mockito-kotlin | 原生 Kotlin 支持,协程模拟,更好的 Kotlin DSL |
| runTest > runBlocking 用于测试 | runBlocking | 更好的时间控制,自动超时,避免测试中的主线程阻塞 |
| FunSpec > StringSpec 用于复杂测试 | StringSpec | 更好的组织,嵌套能力,命名测试组 |
配置要求
build.gradle.kts 依赖
dependencies {
// Kotest
testImplementation("io.kotest:kotest-runner-junit5:5.8.0")
testImplementation("io.kotest:kotest-assertions-core:5.8.0")
testImplementation("io.kotest:kotest-property:5.8.0")
// MockK
testImplementation("io.mockk:mockk:1.13.10")
// 协程测试
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0")
// Kover(插件)
// id("org.jetbrains.kotlinx.kover") version "0.7.5"
// Ktor 测试
// testImplementation("io.ktor:ktor-server-test-host:2.3.9")
}
settings.gradle.kts 插件
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
}
}
rootProject.name = "kotlin-testing-examples"