第13章 测试与调试:单元测试(JUnit5)、集成测试(TestHost)、MockK模拟、性能测试
说实话,很多做Android转后端的朋友,最头疼的就是测试。我当年刚接触Ktor时也踩过不少坑——写接口一时爽,测试火葬场。但后来我发现,只要把测试体系搭好了,开发效率反而能翻倍。这一章我就把压箱底的经验掏出来,咱们一起把Ktor的测试玩明白。
本章核心脉络:从最底层的单元测试开始,逐步向上构建集成测试,最后用性能测试收尾。每一层都有对应的工具和最佳实践。
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") }
}
注意 coEvery 和 coVerify 前面的 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分钟测试才能放心,说明测试设计有问题。
好了,这一章的内容就到这里。测试是个慢功夫,但值得投入。下次你上线前跑一遍测试,发现所有绿灯都亮了,那种踏实感,比什么都强。