Kotlin: Kotlin测试JUnit5与MockK详解

最后更新:2026-08-26

Bob 的信条:没有测试的代码就是未验证的假设。JUnit 5 + MockK 是 Kotlin 测试的黄金组合——MockK 为 Kotlin 原生设计(支持协程 mock),JUnit 5 提供现代测试框架。

1. 你将学到


2. 一个测试负责人的真实故事

(1) 痛点:Mockito 与 Kotlin 不兼容

Bob 用 Mockito mock Kotlin 类时频繁遇到 Final class 错误(Kotlin 类默认 final),需要配置 扩展。协程 函数更是完全无法 mock。

(2) MockK 的解法

KOTLIN
// Mockito: problems with final classes and suspend functions
when(repo.findById("ORD-001")).thenReturn(order)  // Fails on final class!

// MockK: Kotlin native, supports everything
every { repo.findById("ORD-001") } returns order  // Works on any class!
coEvery { repo.fetchOrderAsync("ORD-001") } returns order  // suspend functions!

MockK 为 Kotlin 原生设计——无需额外配置,完美支持 final 类、扩展函数和协程。


3. JUnit 5 基础

(1) 依赖配置

KOTLIN
dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:5.10.1")
    testImplementation("io.mockk:mockk:1.13.8")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
}

tasks.test {
    useJUnitPlatform()
}

(2) 基本测试

KOTLIN
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.*

class OrderTest {
    @Test
    fun `should create order with correct id`() {
        val order = Order("ORD-001", 299.99, "PENDING")
        assertEquals("ORD-001", order.id)
        assertEquals(299.99, order.total)
        assertEquals("PENDING", order.status)
    }

    @Test
    fun `should throw on negative total`() {
        assertThrows(IllegalArgumentException::class.java) {
            Order("ORD-001", -50.0, "PENDING")
        }
    }
}

(3) 参数化测试

KOTLIN
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource

class OrderPriorityTest {
    @ParameterizedTest
    @CsvSource(
        "100.0, LOW",
        "1500.0, MEDIUM",
        "15000.0, HIGH"
    )
    fun `should determine priority based on total`(total: Double, expected: String) {
        val priority = when {
            total > 10_000 -> "HIGH"
            total > 1_000 -> "MEDIUM"
            else -> "LOW"
        }
        assertEquals(expected, priority)
    }
}

(4) 嵌套测试

KOTLIN
class OrderProcessorTest {
    @Nested
    inner class OrderCreation {
        @Test
        fun `should create pending order`() { ... }

        @Test
        fun `should reject negative total`() { ... }
    }

    @Nested
    inner class OrderProcessing {
        @Test
        fun `should confirm pending order`() { ... }

        @Test
        fun `should reject confirming shipped order`() { ... }
    }
}

4. MockK 基础

(1) Mock 创建与存根

KOTLIN
import io.mockk.*

class OrderServiceTest {
    // Create mock
    val repo = mockk`<OrderRepository>`()
    val service = OrderService(repo)

    @Test
    fun `should find order by id`() {
        // Stub: define behavior
        every { repo.findById("ORD-001") } returns Order("ORD-001", 299.99, "PENDING")
        every { repo.findById(any()) } returns null

        // Act
        val order = service.findOrder("ORD-001")

        // Assert
        assertEquals(299.99, order?.total)

        // Verify: check interaction
        verify(exactly = 1) { repo.findById("ORD-001") }
    }
}

(2) MockK 常用 API

API 用途 示例
存根返回值
存根抛异常
动态计算
验证调用
验证调用顺序
确认无未验证调用
协程存根
协程验证

(3) MockK vs Mockito

维度 Mockito MockK
Kotlin final 类 需额外配置 原生支持
协程 mock 不支持 /
扩展函数 mock 不支持
对象 mock 困难
语法 Java 风格 Kotlin DSL 风格

5. 协程测试

(1) runTest

KOTLIN
import kotlinx.coroutines.test.runTest

class OrderServiceTest {
    val repo = mockk`<OrderRepository>`()
    val service = OrderService(repo)

    @Test
    fun `should fetch order asynchronously`() = runTest {
        // Given
        coEvery { repo.fetchOrder("ORD-001") } returns Order("ORD-001", 299.99, "PENDING")

        // When
        val order = service.fetchOrderAsync("ORD-001")

        // Then
        assertEquals(299.99, order.total)
        coVerify { repo.fetchOrder("ORD-001") }
    }
}

(2) 协程测试工具

工具 用途
协程测试入口(替代 runBlocking)
协程函数存根
协程函数验证
控制协程调度(虚拟时间)
推进所有待处理协程

6. 测试生命周期

KOTLIN
class OrderServiceTest {
    private lateinit var repo: OrderRepository
    private lateinit var service: OrderService

    @BeforeEach
    fun setup() {
        repo = mockk()
        service = OrderService(repo)
    }

    @AfterEach
    fun cleanup() {
        unmockkAll()  // Clear all mocks
    }

    @Test
    fun `should process order`() { ... }
}

(1) 生命周期注解

注解 执行时机 用途
每个测试前 初始化 mock 和测试对象
每个测试后 清理资源
所有测试前(需伴生对象) 昂贵的一次性初始化
所有测试后 全局清理

7. 测试金字塔与流程

100%
flowchart TD
    A[Unit Tests<br/>MockK + JUnit5<br/>Fast, isolated] --> B[Integration Tests<br/>Spring Boot Test<br/>Real DB/HTTP]
    B --> C[E2E Tests<br/>TestContainers<br/>Full stack]
    A --> D[runTest for coroutines]
    A --> E[MockK for dependencies]
    D --> F[Virtual time control]

8. 完整示例:OrderProcessorTest

KOTLIN
// ============================================
// OrderProcessor - Test Suite
// Feature: Full test coverage with JUnit5 + MockK
// ============================================

import org.junit.jupiter.api.*
import org.junit.jupiter.api.Assertions.*
import io.mockk.*
import kotlinx.coroutines.test.runTest

// Domain classes
data class Order(val id: String, val total: Double, var status: String, val customerId: String)

interface OrderRepository {
    fun findById(id: String): Order?
    fun save(order: Order)
    suspend fun fetchOrderAsync(id: String): Order
}

class OrderService(private val repo: OrderRepository) {
    fun findOrder(id: String): Order = repo.findById(id) ?: throw NoSuchElementException("Order $id not found")

    fun processOrder(id: String): Order {
        val order = findOrder(id)
        if (order.status != "PENDING") throw IllegalStateException("Order $id is not pending")
        order.status = "CONFIRMED"
        repo.save(order)
        return order
    }

    suspend fun fetchOrderAsync(id: String): Order = repo.fetchOrderAsync(id)

    fun calculatePriority(order: Order): String = when {
        order.total > 10_000 -> "HIGH"
        order.total > 1_000 -> "MEDIUM"
        else -> "LOW"
    }
}

class OrderServiceTest {
    private lateinit var repo: OrderRepository
    private lateinit var service: OrderService

    @BeforeEach
    fun setup() {
        repo = mockk(relaxed = true)  // Relaxed: returns default values for unstubbed methods
        service = OrderService(repo)
    }

    @AfterEach
    fun cleanup() {
        unmockkAll()
    }

    @Nested
    @DisplayName("Order Retrieval")
    inner class OrderRetrieval {
        @Test
        fun `should find existing order`() {
            // Given
            every { repo.findById("ORD-001") } returns Order("ORD-001", 299.99, "PENDING", "CUST-001")

            // When
            val order = service.findOrder("ORD-001")

            // Then
            assertEquals("ORD-001", order.id)
            assertEquals(299.99, order.total)
        }

        @Test
        fun `should throw when order not found`() {
            every { repo.findById(any()) } returns null
            assertThrows(NoSuchElementException::class.java) {
                service.findOrder("ORD-999")
            }
        }
    }

    @Nested
    @DisplayName("Order Processing")
    inner class OrderProcessing {
        @Test
        fun `should confirm pending order`() {
            every { repo.findById("ORD-001") } returns Order("ORD-001", 299.99, "PENDING", "CUST-001")
            every { repo.save(any()) } just Runs

            val processed = service.processOrder("ORD-001")

            assertEquals("CONFIRMED", processed.status)
            verify { repo.save(match { it.status == "CONFIRMED" }) }
        }

        @Test
        fun `should reject non-pending order`() {
            every { repo.findById("ORD-001") } returns Order("ORD-001", 299.99, "SHIPPED", "CUST-001")

            assertThrows(IllegalStateException::class.java) {
                service.processOrder("ORD-001")
            }
            verify(exactly = 0) { repo.save(any()) }
        }
    }

    @Nested
    @DisplayName("Priority Calculation")
    inner class PriorityCalculation {
        @Test
        fun `should assign HIGH priority for orders over 10000`() {
            val order = Order("ORD-001", 15_000.00, "PENDING", "CUST-001")
            assertEquals("HIGH", service.calculatePriority(order))
        }

        @Test
        fun `should assign MEDIUM priority for orders between 1000 and 10000`() {
            val order = Order("ORD-001", 2_500.00, "PENDING", "CUST-001")
            assertEquals("MEDIUM", service.calculatePriority(order))
        }

        @Test
        fun `should assign LOW priority for orders under 1000`() {
            val order = Order("ORD-001", 299.99, "PENDING", "CUST-001")
            assertEquals("LOW", service.calculatePriority(order))
        }
    }

    @Nested
    @DisplayName("Async Operations")
    inner class AsyncOperations {
        @Test
        fun `should fetch order asynchronously`() = runTest {
            coEvery { repo.fetchOrderAsync("ORD-001") } returns Order("ORD-001", 299.99, "PENDING", "CUST-001")

            val order = service.fetchOrderAsync("ORD-001")

            assertEquals("ORD-001", order.id)
            coVerify { repo.fetchOrderAsync("ORD-001") }
        }
    }
}

输出:所有测试通过 ✅


❓ 常见问题

Q MockK 的 relaxed mock 和普通 mock 有什么区别?
A Relaxed mock()对未存根的方法返回默认值(0/null/false),普通 mock 对未存根方法抛 MockKException。推荐用普通 mock,避免隐藏未设置的行为。
Q runTest 和 runBlocking 有什么区别?
A 使用虚拟时间(跳过 delay),测试秒级完成; 使用真实时间,delay 多久就等多久。协程测试必须用 。
Q 如何 mock 伴生对象?
A 用 mock 整个伴生对象,。测试后用 恢复。
Q 如何 mock 扩展函数?
A 用 mock 特定扩展函数。但尽量避免——扩展函数通常可以重构为普通函数以提高可测试性。
Q 测试应该覆盖多少百分比?
A 目标 80% 行覆盖,但覆盖率不等于质量。关键业务逻辑(支付、库存、状态机)应 100% 覆盖,简单 data class 可以不测试。
Q 集成测试和单元测试怎么区分?
A 单元测试 mock 所有依赖(测试单个类),集成测试使用真实依赖(数据库、HTTP)。单元测试快且稳定,集成测试慢但更真实。

📖 小节


📝 作业

  1. 基础题(难度⭐):为 编写 JUnit 5 测试,验证 和 行为。提示:
  2. 进阶题(难度⭐⭐):用 MockK mock ,测试 的正常和异常路径。提示:
  3. 挑战题(难度⭐⭐⭐):用 + 测试异步订单获取函数,验证协程调用的时序。提示:

← 上一课 | 下一课 →

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏