第26章 测试:单元测试(JUnit)、Compose UI测试(ComposeTestRule)、MockK模拟、测试协程
说实话,很多Android开发者对测试的态度是「知道很重要,但就是不想写」。我以前也这样,觉得测试浪费时间,不如多写几个功能。直到有一次,我改了一个看似无关紧要的工具类,结果把整个登录流程搞崩了——而且是在发版前一天才发现的。嗯,从那以后,我再也不敢不写测试了。
这一章,我会带你系统过一遍Kotlin Android开发中的测试体系。包括单元测试、Compose UI测试、MockK模拟,以及协程测试。内容不少,但都是实战干货。
核心要点:测试不是「额外工作」,而是「安全网」。写测试就像买保险——你希望永远用不上,但真出事时它能救命。
26.1 单元测试:JUnit5 + Kotlin
单元测试,说白了就是测试你的「一个函数」或「一个类」能不能正常工作。不依赖Android框架,纯JVM运行,所以跑得飞快。
我个人习惯用JUnit5,因为它支持参数化测试、嵌套测试,写起来比JUnit4灵活很多。
26.1.1 基础配置
在 build.gradle.kts 里加上依赖:
// 单元测试依赖
testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
testImplementation("org.junit.jupiter:junit-jupiter-params:5.10.0")
注意是 testImplementation,不是 androidTestImplementation。前者跑在本地JVM,后者跑在设备上。
26.1.2 写一个简单的测试
假设我们有一个计算器类:
class Calculator {
fun add(a: Int, b: Int): Int = a + b
fun divide(a: Int, b: Int): Int {
if (b == 0) throw IllegalArgumentException("除数不能为0")
return a / b
}
}
对应的测试:
class CalculatorTest {
private val calculator = Calculator()
@Test
fun `加法应该返回正确结果`() {
val result = calculator.add(2, 3)
assertEquals(5, result)
}
@Test
fun `除以0应该抛出异常`() {
assertThrows<IllegalArgumentException> {
calculator.divide(1, 0)
}
}
}
你看,测试方法名我用了反引号括起来的自然语言。这是Kotlin的特性,可读性非常好。我团队里现在都这么写,review代码时一眼就能看出测试意图。
小技巧:测试方法名用「应该」结尾,比如「登录失败应该返回错误提示」。这样写出来的测试就像一份需求文档。
26.1.3 参数化测试
有时候同一个逻辑需要测多组数据。JUnit5的参数化测试很好用:
@ParameterizedTest
@CsvSource(
"1, 2, 3",
"0, 0, 0",
"-1, 1, 0",
"100, 200, 300"
)
fun `加法参数化测试`(a: Int, b: Int, expected: Int) {
assertEquals(expected, calculator.add(a, b))
}
这样一组测试就能覆盖多种情况,不用写一堆重复的 @Test 方法。
26.2 MockK模拟:告别Mockito的繁琐
做单元测试时,经常需要模拟外部依赖。比如你的ViewModel里调用了Repository,测试时你不想真的去访问网络或数据库——这时候就需要Mock。
我以前用Mockito,但说实话,在Kotlin里用Mockito挺别扭的。尤其是对 final 类、object 单例的支持很差。后来换成了MockK,舒服多了。
26.2.1 基本用法
// 模拟一个接口
val repository = mockk<UserRepository>()
// 定义行为
every { repository.getUserName(1) } returns "张三"
// 调用
val name = repository.getUserName(1)
assertEquals("张三", name)
// 验证调用次数
verify(exactly = 1) { repository.getUserName(1) }
你看,语法非常自然。 every 定义「当调用这个方法时,返回什么」, verify 检查「这个方法被调用了多少次」。
26.2.2 模拟协程函数
Repository里很多方法是 suspend 的。MockK对协程的支持也很到位:
val repository = mockk<UserRepository>()
// 模拟挂起函数
coEvery { repository.fetchUser(1) } returns User(id = 1, name = "张三")
// 在协程中调用
runBlocking {
val user = repository.fetchUser(1)
assertEquals("张三", user.name)
}
// 验证协程调用
coVerify { repository.fetchUser(1) }
注意 coEvery 和 coVerify 这两个前缀。MockK通过 co 前缀来区分普通函数和挂起函数。我在项目中遇到过一个问题:忘了加 co 前缀,结果Mock一直不生效,排查了半天才发现是这个问题。
注意:模拟挂起函数时,一定要用 coEvery 而不是 every。否则MockK会报错,或者更糟——静默失败,让你以为Mock生效了,实际上没有。
26.2.3 验证调用顺序
有时候我们需要确保方法按特定顺序调用。比如先登录,再获取用户信息:
val repo = mockk<UserRepository>()
every { repo.login(any()) } returns true
every { repo.getProfile(any()) } returns Profile()
// 执行
repo.login("token123")
repo.getProfile("token123")
// 验证顺序
verifyOrder {
repo.login("token123")
repo.getProfile("token123")
}
这个 verifyOrder 在测试复杂的业务流程时特别有用。
26.3 测试协程:runBlocking与TestCoroutineDispatcher
协程测试是Android开发里绕不开的坎。你想想看,ViewModel里到处都是 viewModelScope.launch,Repository里全是 suspend 函数——不搞定协程测试,基本等于没测。
26.3.1 基础:runBlocking
最简单的办法就是用 runBlocking 把协程代码包起来:
@Test
fun `测试挂起函数`() = runBlocking {
val result = someSuspendFunction()
assertEquals("expected", result)
}
但 runBlocking 有个问题:它不会自动控制时间。如果你的协程里有 delay,它会真的等那么久。测试里如果有1秒的 delay,整个测试就得跑1秒——这显然不行。
26.3.2 进阶:TestCoroutineDispatcher
我推荐用 TestCoroutineDispatcher。它能「虚拟时间」,让 delay 瞬间跳过:
class CoroutineTest {
private val testDispatcher = TestCoroutineDispatcher()
@Test
fun `测试带delay的协程`() = testDispatcher.runBlockingTest {
// 这里面的 delay 会被瞬间跳过
delay(1000) // 不会真的等1秒
assertEquals(true, true)
}
@After
fun tearDown() {
testDispatcher.cleanupTestCoroutines()
}
}
注意 runBlockingTest 和 runBlocking 的区别。前者是测试专用的,会使用虚拟时间。后者是真实的阻塞。
最佳实践:在ViewModel里,把 Dispatchers.IO 和 Dispatchers.Main 设计成可注入的。这样测试时就能替换成 TestCoroutineDispatcher,避免真的切线程。
26.3.3 测试ViewModel中的协程
假设我们有这样一个ViewModel:
class UserViewModel(
private val repository: UserRepository,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : ViewModel() {
private val _user = MutableStateFlow<User?>(null)
val user: StateFlow<User?> = _user
fun loadUser(id: Int) {
viewModelScope.launch(dispatcher) {
val result = repository.fetchUser(id)
_user.value = result
}
}
}
测试代码:
class UserViewModelTest {
private val testDispatcher = TestCoroutineDispatcher()
private val repository = mockk<UserRepository>()
@Test
fun `加载用户应该更新状态`() = testDispatcher.runBlockingTest {
// 准备
coEvery { repository.fetchUser(1) } returns User(id = 1, name = "张三")
// 执行
val viewModel = UserViewModel(repository, testDispatcher)
viewModel.loadUser(1)
// 验证
assertEquals("张三", viewModel.user.value?.name)
coVerify { repository.fetchUser(1) }
}
@After
fun tearDown() {
testDispatcher.cleanupTestCoroutines()
}
}
这里的关键是把 dispatcher 作为构造参数传入。测试时传 testDispatcher,生产环境传 Dispatchers.IO。这样既不影响线上代码,又能方便测试。
26.4 Compose UI测试:ComposeTestRule
Compose的UI测试比传统的View系统简单太多了。核心就是 ComposeTestRule,它让你能直接操作Composable组件,检查UI状态。
26.4.1 基础配置
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.5.0")
debugImplementation("androidx.compose.ui:ui-test-manifest:1.5.0")
注意 ui-test-manifest 要用 debugImplementation,因为它只在debug模式下需要。
26.4.2 写一个Compose UI测试
假设我们有一个简单的登录界面:
@Composable
fun LoginScreen(
onLogin: (String, String) -> Unit
) {
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
Column(modifier = Modifier.padding(16.dp)) {
TextField(
value = username,
onValueChange = { username = it },
label = { Text("用户名") },
modifier = Modifier.testTag("username_input")
)
TextField(
value = password,
onValueChange = { password = it },
label = { Text("密码") },
modifier = Modifier.testTag("password_input")
)
Button(
onClick = { onLogin(username, password) },
modifier = Modifier.testTag("login_button")
) {
Text("登录")
}
}
}
对应的UI测试:
class LoginScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun `输入用户名和密码后点击登录`() {
// 设置Composable
composeTestRule.setContent {
LoginScreen(onLogin = { user, pwd ->
// 这里可以验证回调
})
}
// 输入用户名
composeTestRule
.onNodeWithTag("username_input")
.performTextInput("admin")
// 输入密码
composeTestRule
.onNodeWithTag("password_input")
.performTextInput("123456")
// 点击登录按钮
composeTestRule
.onNodeWithTag("login_button")
.performClick()
// 验证输入框内容
composeTestRule
.onNodeWithTag("username_input")
.assertTextEquals("admin")
}
}
你看,整个过程非常直观。用 testTag 标记组件,然后用 onNodeWithTag 找到它,再执行操作或断言。
个人经验:给每个可交互的组件都加上 testTag。虽然写的时候多花几秒钟,但测试时能省大量时间。我团队现在把 testTag 写进了代码规范里。
26.4.3 测试列表滚动
列表测试是UI测试里的难点。Compose里可以用 performScrollToIndex 或 performScrollToNode:
@Test
fun `滚动到列表第50项`() {
composeTestRule.setContent {
LazyColumn {
items(100) { index ->
Text("Item $index", modifier = Modifier.testTag("item_$index"))
}
}
}
// 滚动到第50项
composeTestRule
.onNodeWithTag("item_50")
.performScrollTo()
// 验证可见
composeTestRule
.onNodeWithTag("item_50")
.assertIsDisplayed()
}
注意 performScrollTo 会自动处理滚动,不需要手动计算偏移量。这一点比传统的RecyclerView测试方便太多了。
26.5 知识体系总览
下面这张图总结了本章的核心内容,你可以把它当作测试的「地图」:
26.6 避坑指南
最后分享几个我踩过的坑,希望能帮你少走弯路:
- MockK的
relaxed模式要慎用。 如果你用mockk<T>(relaxed = true),所有未定义的方法都会返回默认值。这看起来很省事,但容易掩盖问题。我建议默认用严格模式,只在需要时才用relaxed。 - Compose测试里不要用
Thread.sleep。 用composeTestRule.waitForIdle()或者waitUntil来等待异步操作完成。用sleep会让测试变慢,而且不稳定。 - 测试协程时记得清理
TestCoroutineDispatcher。 在@After方法里调用cleanupTestCoroutines(),否则可能会影响后续测试。 - 不要测试私有方法。 只测试公开API。如果你觉得某个私有方法需要单独测试,说明它应该被提取成一个独立的类。
测试这件事,说白了就是「先苦后甜」。刚开始写测试时确实会觉得慢,但一旦项目变大、人员变多,测试就是你的救命稻草。我见过太多项目因为没测试,改一行代码要手动测半小时——那才是真正的浪费时间。