第13章 测试与调试:单元测试(JUnit5)、集成测试(TestHost)、MockK模拟、性能测试

说实话,很多做Android转后端的朋友,最头疼的就是测试。我当年刚接触Ktor时也踩过不少坑——写接口一时爽,测试火葬场。但后来我发现,只要把测试体系搭好了,开发效率反而能翻倍。这一章我就把压箱底的经验掏出来,咱们一起把Ktor的测试玩明白。

本章核心脉络:从最底层的单元测试开始,逐步向上构建集成测试,最后用性能测试收尾。每一层都有对应的工具和最佳实践。

Ktor 测试体系全景图 单元测试 JUnit 5 集成测试 TestHost Mock模拟 MockK 性能测试 k6 / Gatling 测试金字塔:从底层到上层 速度递减 · 覆盖范围递增 工具链:JUnit 5 + TestHost + MockK + k6/Gatling

13.1 单元测试:用JUnit 5给代码上保险

单元测试是测试金字塔的底座。说白了,就是测试你的每一个函数、每一个类在孤立环境下能不能正常工作。我见过太多人一上来就写集成测试,结果出了问题根本定位不到是哪个模块的锅。

13.1.1 为什么选JUnit 5?

JUnit 5相比4.x版本,最大的改进就是支持了Lambda表达式和更灵活的扩展机制。在Kotlin项目里用起来特别顺手。我个人习惯把测试类命名为 XxxTest,放在 src/test/kotlin 目录下。

💡 小技巧:在IntelliJ IDEA里,按 Ctrl+Shift+T 可以快速在源码和测试类之间跳转。这个快捷键我一天能用几十次。

13.1.2 实战:测试一个Service层

假设我们有一个 UserService,它依赖 UserRepository。先看代码:

class UserService(private val repository: UserRepository) {
    fun getUserById(id: Long): User {
        require(id > 0) { "ID必须大于0" }
        return repository.findById(id) 
            ?: throw UserNotFoundException("用户不存在: $id")
    }
}

对应的单元测试怎么写?

class UserServiceTest {

    private lateinit var service: UserService
    private lateinit var repository: UserRepository

    @BeforeEach
    fun setUp() {
        repository = mockk()
        service = UserService(repository)
    }

    @Test
    fun `应该返回用户信息当ID有效时`() {
        // 准备
        val userId = 1L
        val expectedUser = User(userId, "张三")
        every { repository.findById(userId) } returns expectedUser

        // 执行
        val result = service.getUserById(userId)

        // 验证
        assertEquals(expectedUser, result)
        verify { repository.findById(userId) }
    }

    @Test
    fun `应该抛出异常当ID为负数时`() {
        assertThrows<IllegalArgumentException> {
            service.getUserById(-1L)
        }
    }
}

这里有个细节:@BeforeEach 在每个测试方法执行前都会运行。我曾经犯过一个错误,把Mock初始化放在了 @BeforeAll 里,结果多个测试之间状态互相污染,排查了半天。

⚠️ 注意:JUnit 5的测试方法默认是 internal 可见性,不需要写成 public。这是Kotlin和Java的一个区别,别搞混了。

13.2 MockK模拟:让依赖乖乖听话

MockK是Kotlin生态里最流行的Mock框架。为什么不用Mockito?因为Mockito对Kotlin的支持不够好,尤其是对 final 类、扩展函数、协程的支持。MockK就是为Kotlin量身定做的。

13.2.1 核心概念

  • mockk():创建一个Mock对象
  • every { ... }:定义Mock行为
  • verify { ... }:验证方法是否被调用
  • coEvery / coVerify:用于挂起函数(suspend)

13.2.2 协程Mock实战

Ktor里大量使用协程,MockK对协程的支持是一大亮点。看个例子:

class OrderService(private val paymentClient: PaymentClient) {
    suspend fun processOrder(order: Order): PaymentResult {
        return paymentClient.charge(order.amount, order.currency)
    }
}

// 测试代码
@Test
fun `应该成功处理订单`() = runTest {
    val client = mockk<PaymentClient>()
    val service = OrderService(client)
    val order = Order(amount = 99.99, currency = "CNY")

    coEvery { client.charge(any(), any()) } returns PaymentResult.Success

    val result = service.processOrder(order)

    assertTrue(result is PaymentResult.Success)
    coVerify { client.charge(99.99, "CNY") }
}

注意 coEverycoVerify 前面的 co 前缀,这是MockK专门为协程提供的API。我刚开始用的时候老忘记加,结果Mock死活不生效,折腾了一下午才发现问题。

🔑 关键点:MockK的 relaxed = true 参数可以创建宽松Mock——没定义行为的方法会返回默认值。调试阶段很好用,但生产测试不建议用,容易掩盖问题。

13.3 集成测试:用TestHost模拟真实请求

单元测试只能测逻辑,测不了路由、拦截器、序列化这些。这时候就需要集成测试了。Ktor官方提供了 TestHost,可以在不启动服务器的情况下模拟HTTP请求。

13.3.1 TestHost基础用法

class UserRouteTest {

    @Test
    fun `GET 用户列表应该返回200`() = testApplication {
        // 配置应用
        application {
            configureRouting()
            configureSerialization()
        }

        // 发送请求
        val response = client.get("/api/users")

        // 断言
        assertEquals(HttpStatusCode.OK, response.status)
        val body = response.body<List<User>>()
        assertTrue(body.isNotEmpty())
    }
}

testApplication 是Ktor 2.x引入的DSL,比老版本的 withTestApplication 更直观。它会自动启动一个测试服务器,监听随机端口。

13.3.2 模拟数据库依赖

实际项目中,路由处理函数通常会依赖数据库。我们可以注入Mock的Repository:

class UserRouteIntegrationTest {

    private val mockRepo = mockk<UserRepository>()

    @Test
    fun `应该返回用户详情`() = testApplication {
        // 注入Mock依赖
        environment {
            config = MapApplicationConfig(
                "app.db.url" to "jdbc:h2:mem:test"
            )
        }

        // 替换真实依赖
        val module: Application.() -> Unit = {
            // 用Mock替换真实Repository
            val originalModule = { /* 原始模块配置 */ }
            // 这里用依赖注入框架替换,比如Koin
        }

        application {
            // 配置路由
            routing {
                getUserRoute(mockRepo)
            }
        }

        every { mockRepo.findById(1L) } returns User(1L, "测试用户")

        val response = client.get("/api/users/1")
        assertEquals("测试用户", response.body<UserResponse>().name)
    }
}

💡 经验之谈:我建议每个路由都写一个集成测试。虽然写起来比单元测试慢,但能发现很多单元测试发现不了的问题,比如JSON字段名不匹配、状态码错误等。

13.4 性能测试:别让API在线上崩了

性能测试是最后一道防线。我见过太多项目,单元测试全绿,集成测试也过了,结果上线第一天就被用户量打趴下。性能测试不是可选项,是必选项。

13.4.1 工具选择

工具 适用场景 优点 缺点
k6 HTTP API压测 脚本简单,支持JavaScript 不支持WebSocket
Gatling 复杂场景模拟 支持Scala/Java DSL 学习曲线陡
JMeter 传统企业级 GUI操作,插件丰富 资源消耗大

我个人推荐k6,轻量、好用。写个简单的压测脚本:

// k6脚本示例
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
    vus: 10,           // 10个虚拟用户
    duration: '30s',   // 持续30秒
    thresholds: {
        http_req_duration: ['p(95)<500'], // 95%的请求在500ms内
    },
};

export default function () {
    const res = http.get('http://localhost:8080/api/users');
    check(res, {
        '状态码是200': (r) => r.status === 200,
        '响应时间小于200ms': (r) => r.timings.duration < 200,
    });
    sleep(1);
}

13.4.2 性能测试的常见坑

  • 不要压本地开发环境:网络延迟、数据库连接池都会影响结果。最好在预发布环境压测。
  • 先做基准测试:先跑一次单用户请求,确定基线。我习惯先跑10秒单用户,再逐步加压。
  • 关注p95和p99:平均响应时间会骗人。p95才是用户体验的真实反映。

⚠️ 警告:性能测试时一定要监控服务器资源(CPU、内存、连接数)。我曾经有一次压测把数据库连接池打满了,导致线上服务也跟着挂了。后来加了连接池隔离才解决。

13.5 测试策略总结

说了这么多,最后给个实战建议。我一般按这个比例分配测试精力:

  • 单元测试 60%:Service层、Utils工具类、数据校验逻辑
  • 集成测试 30%:路由、拦截器、序列化/反序列化
  • 性能测试 10%:核心接口、高并发场景

这个比例不是固定的。如果你的项目业务逻辑复杂,单元测试比例可以更高。如果主要是CRUD接口,集成测试就要多写一些。

🎯 核心原则:测试不是为了覆盖率数字好看,而是为了让你在改代码时有信心。如果改一行代码需要跑10分钟测试才能放心,说明测试设计有问题。

好了,这一章的内容就到这里。测试是个慢功夫,但值得投入。下次你上线前跑一遍测试,发现所有绿灯都亮了,那种踏实感,比什么都强。


公众号:蓝海资料掘金营,微信deep3321