Kotlin 測試模式
全面的 Kotlin 測試模式,幫助你撰寫可靠、易於維護的測試,遵循 TDD 方法論,搭配 Kotest 與 MockK。
使用時機
- 編寫新的 Kotlin 函數或類別
- 為現有 Kotlin 程式碼增加測試涵蓋率
- 實作屬性基礎測試(Property-based Testing)
- 在 Kotlin 專案中遵循 TDD 工作流程
- 設定 Kover 進行程式碼涵蓋率檢查
運作方式
- 找出目標程式碼 — 找到要測試的函數、類別或模組
- 撰寫 Kotest 規格 — 選擇符合測試範圍的規格風格(StringSpec、FunSpec、BehaviorSpec)
- 模擬相依項目 — 使用 MockK 隔離待測單元
- 執行測試(RED) — 確認測試因預期錯誤而失敗
- 實作程式碼(GREEN) — 撰寫最簡程式碼以通過測試
- 重構 — 在保持測試通過的同時改善實作
- 檢查涵蓋率 — 執行
./gradlew koverHtmlReport並確認 80% 以上的涵蓋率
範例
以下各章節包含每個測試模式的詳細、可執行範例:
快速參考
- Kotest 規格 — StringSpec、FunSpec、BehaviorSpec、DescribeSpec 範例見 Kotest 規格風格
- 模擬 — MockK 設定、協程模擬、參數捕捉見 MockK
- TDD 逐步教學 — 完整 RED/GREEN/REFACTOR 循環(以 EmailValidator 為例)見 Kotlin 的 TDD 工作流程
- 涵蓋率 — Kover 設定與指令見 Kover 涵蓋率
- Ktor 測試 — testApplication 設定見 Ktor 的 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({
"有效的電子郵件回傳成功" {
validateEmail("[email protected]").shouldBeSuccess("[email protected]")
}
"空的電子郵件回傳失敗" {
validateEmail("").shouldBeFailure()
}
"缺少 @ 的電子郵件回傳失敗" {
validateEmail("userexample.com").shouldBeFailure()
}
})
// 步驟 3:執行測試 — 確認失敗
// $ ./gradlew test
// EmailValidatorTest > 有效的電子郵件回傳成功 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 > 有效的電子郵件回傳成功 PASSED
// EmailValidatorTest > 空的電子郵件回傳失敗 PASSED
// EmailValidatorTest > 缺少 @ 的電子郵件回傳失敗 PASSED
// 步驟 6:視需要重構,並確認測試仍然通過
Kotest 規格風格
StringSpec(最簡潔)
class CalculatorTest : StringSpec({
"兩個正數相加" {
Calculator.add(2, 3) shouldBe 5
}
"負數相加" {
Calculator.add(-1, -2) shouldBe -3
}
"加零" {
Calculator.add(0, 5) shouldBe 5
}
})
FunSpec(類似 JUnit)
class UserServiceTest : FunSpec({
val repository = mockk<UserRepository>()
val service = UserService(repository)
test("getUser 在使用者存在時回傳使用者") {
val expected = User(id = "1", name = "Alice")
coEvery { repository.findById("1") } returns expected
val result = service.getUser("1")
result shouldBe expected
}
test("getUser 在使用者不存在時拋出例外") {
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("一個有效的訂單請求") {
val request = CreateOrderRequest(
userId = "user-1",
items = listOf(OrderItem("product-1", quantity = 2)),
)
When("訂單被提交") {
coEvery { paymentService.charge(any()) } returns PaymentResult.Success
coEvery { repository.save(any()) } answers { firstArg() }
val result = service.placeOrder(request)
Then("它應該回傳已確認的訂單") {
result.status shouldBe OrderStatus.CONFIRMED
}
Then("它應該扣款") {
coVerify(exactly = 1) { paymentService.charge(any()) }
}
}
When("付款失敗") {
coEvery { paymentService.charge(any()) } returns PaymentResult.Declined
Then("它應該拋出 PaymentException") {
shouldThrow<PaymentException> {
service.placeOrder(request)
}
}
}
}
})
DescribeSpec(RSpec 風格)
class UserValidatorTest : DescribeSpec({
describe("validateUser") {
val validator = UserValidator()
context("輸入有效時") {
it("接受一般使用者") {
val user = CreateUserRequest("Alice", "[email protected]")
validator.validate(user).shouldBeValid()
}
}
context("名稱無效時") {
it("拒絕空白名稱") {
val user = CreateUserRequest("", "[email protected]")
validator.validate(user).shouldBeInvalid()
}
it("拒絕超過最大長度的名稱") {
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,
{ "使用者 ${value.id} 應為活躍狀態,且有最後登入時間" },
{ "使用者 ${value.id} 不應為活躍狀態" },
)
}
// 使用
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 委派給 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 對於未知 ID 回傳 null") {
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 暫停函數") {
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 含延遲") {
coEvery { repository.findById("1") } coAnswers {
delay(100) // 模擬非同步工作
User(id = "1", name = "Alice")
}
val result = service.getUser("1")
result.name shouldBe "Alice"
}
})
參數捕捉
test("save 捕捉使用者參數") {
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()
}
Spy 與部分模擬
test("監聽真實物件") {
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("並行擷取同時完成") {
runTest {
val service = DataService(testScope = this)
val result = service.fetchAllData()
result.users.shouldNotBeEmpty()
result.products.shouldNotBeEmpty()
}
}
test("延遲後超時") {
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 發出更新") {
runTest {
val service = UserFlowService()
val emissions = service.observeUsers()
.take(3)
.toList()
emissions shouldHaveSize 3
emissions.last().shouldNotBeEmpty()
}
}
test("searchUsers 對輸入進行去抖") {
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("使用測試排程器控制執行") {
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("字串反轉是對合運算") {
forAll<String> { s ->
s.reversed().reversed() == s
}
}
test("列表排序是冪等的") {
forAll(Arb.list(Arb.int())) { list ->
list.sorted() == list.sorted().sorted()
}
}
test("序列化往返保留資料") {
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
}
}
})
自訂生成器
[為翻譯而截斷]