mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-09 02:43:29 +08:00
827 lines
20 KiB
Markdown
827 lines
20 KiB
Markdown
---
|
||
name: kotlin-testing
|
||
description: 使用Kotest、MockK、协程测试、基于属性的测试和Kover覆盖率的Kotlin测试模式。遵循TDD方法论和地道的Kotlin实践。
|
||
origin: ECC
|
||
---
|
||
|
||
# Kotlin 测试模式
|
||
|
||
遵循 TDD 方法论,使用 Kotest 和 MockK 编写可靠、可维护测试的全面 Kotlin 测试模式。
|
||
|
||
## 何时使用
|
||
|
||
* 编写新的 Kotlin 函数或类
|
||
* 为现有 Kotlin 代码添加测试覆盖率
|
||
* 实现基于属性的测试
|
||
* 在 Kotlin 项目中遵循 TDD 工作流
|
||
* 为代码覆盖率配置 Kover
|
||
|
||
## 工作原理
|
||
|
||
1. **确定目标代码** — 找到要测试的函数、类或模块
|
||
2. **编写 Kotest 规范** — 选择与测试范围匹配的规范样式(StringSpec、FunSpec、BehaviorSpec)
|
||
3. **模拟依赖项** — 使用 MockK 来隔离被测单元
|
||
4. **运行测试(红色阶段)** — 验证测试是否按预期失败
|
||
5. **实现代码(绿色阶段)** — 编写最少的代码以使测试通过
|
||
6. **重构** — 改进实现,同时保持测试通过
|
||
7. **检查覆盖率** — 运行 `./gradlew koverHtmlReport` 并验证 80%+ 的覆盖率
|
||
|
||
## 示例
|
||
|
||
以下部分包含每个测试模式的详细、可运行示例:
|
||
|
||
### 快速参考
|
||
|
||
* **Kotest 规范** — [Kotest 规范样式](#kotest-规范样式) 中的 StringSpec、FunSpec、BehaviorSpec、DescribeSpec 示例
|
||
* **模拟** — [MockK](#mockk) 中的 MockK 设置、协程模拟、参数捕获
|
||
* **TDD 演练** — [Kotlin 的 TDD 工作流](#kotlin-的-tdd-工作流) 中 EmailValidator 的完整 RED/GREEN/REFACTOR 周期
|
||
* **覆盖率** — [Kover 覆盖率](#kover-覆盖率) 中的 Kover 配置和命令
|
||
* **Ktor 测试** — [Ktor testApplication 测试](#ktor-testapplication-测试) 中的 testApplication 设置
|
||
|
||
### Kotlin 的 TDD 工作流
|
||
|
||
#### RED-GREEN-REFACTOR 周期
|
||
|
||
```
|
||
RED -> 首先编写一个失败的测试
|
||
GREEN -> 编写最少的代码使测试通过
|
||
REFACTOR -> 改进代码同时保持测试通过
|
||
REPEAT -> 继续下一个需求
|
||
```
|
||
|
||
#### Kotlin 中逐步进行 TDD
|
||
|
||
```kotlin
|
||
// Step 1: Define the interface/signature
|
||
// EmailValidator.kt
|
||
package com.example.validator
|
||
|
||
fun validateEmail(email: String): Result<String> {
|
||
TODO("not implemented")
|
||
}
|
||
|
||
// Step 2: Write failing test (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("user@example.com").shouldBeSuccess("user@example.com")
|
||
}
|
||
|
||
"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 pass
|
||
```
|
||
|
||
### Kotest 规范样式
|
||
|
||
#### 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", "alice@example.com")
|
||
validator.validate(user).shouldBeValid()
|
||
}
|
||
}
|
||
|
||
context("with invalid name") {
|
||
it("rejects blank name") {
|
||
val user = CreateUserRequest("", "alice@example.com")
|
||
validator.validate(user).shouldBeInvalid()
|
||
}
|
||
|
||
it("rejects name exceeding max length") {
|
||
val user = CreateUserRequest("A".repeat(256), "alice@example.com")
|
||
validator.validate(user).shouldBeInvalid()
|
||
}
|
||
}
|
||
}
|
||
})
|
||
```
|
||
|
||
### Kotest 匹配器
|
||
|
||
#### 核心匹配器
|
||
|
||
```kotlin
|
||
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.*
|
||
|
||
// Equality
|
||
result shouldBe expected
|
||
result shouldNotBe unexpected
|
||
|
||
// Strings
|
||
name shouldStartWith "Al"
|
||
name shouldEndWith "ice"
|
||
name shouldContain "lic"
|
||
name shouldMatch Regex("[A-Z][a-z]+")
|
||
name.shouldBeBlank()
|
||
|
||
// Collections
|
||
list shouldContain "item"
|
||
list shouldHaveSize 3
|
||
list.shouldBeSorted()
|
||
list.shouldContainAll("a", "b", "c")
|
||
list.shouldBeEmpty()
|
||
|
||
// Nulls
|
||
result.shouldNotBeNull()
|
||
result.shouldBeNull()
|
||
|
||
// Types
|
||
result.shouldBeInstanceOf<User>()
|
||
|
||
// Numbers
|
||
count shouldBeGreaterThan 0
|
||
price shouldBeInRange 1.0..100.0
|
||
|
||
// Exceptions
|
||
shouldThrow<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" },
|
||
)
|
||
}
|
||
|
||
// Usage
|
||
user 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", "alice@example.com"))
|
||
|
||
slot.captured.name shouldBe "Alice"
|
||
slot.captured.email shouldBe "alice@example.com"
|
||
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.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") // Only this should trigger search
|
||
advanceTimeBy(500)
|
||
|
||
results shouldHaveSize 1
|
||
job.cancel()
|
||
}
|
||
}
|
||
})
|
||
```
|
||
|
||
#### TestDispatcher
|
||
|
||
```kotlin
|
||
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 属性测试
|
||
|
||
```kotlin
|
||
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
|
||
|
||
// 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@test.com")
|
||
}) { 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] = "alice@example.com"
|
||
}
|
||
}
|
||
|
||
val users = transaction(db) {
|
||
UsersTable.selectAll().map { it[UsersTable.name] }
|
||
}
|
||
|
||
users shouldContain "Alice"
|
||
}
|
||
})
|
||
```
|
||
|
||
#### Kotest 扩展
|
||
|
||
```kotlin
|
||
// Reusable test extension
|
||
class 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.kts
|
||
plugins {
|
||
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", "alice@example.com"))
|
||
}
|
||
|
||
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 example
|
||
test:
|
||
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 来清晰地模拟依赖项。
|