第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) }

注意 coEverycoVerify 这两个前缀。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()
    }
}

注意 runBlockingTestrunBlocking 的区别。前者是测试专用的,会使用虚拟时间。后者是真实的阻塞。

最佳实践:在ViewModel里,把 Dispatchers.IODispatchers.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里可以用 performScrollToIndexperformScrollToNode

@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 知识体系总览

下面这张图总结了本章的核心内容,你可以把它当作测试的「地图」:

Android 测试体系 单元测试 (JUnit5) MockK 模拟 协程测试 Compose UI测试 • @Test / @ParameterizedTest • assertEquals / assertThrows • every / coEvery • verify / coVerify • runBlockingTest • TestCoroutineDispatcher • 虚拟时间控制 • ComposeTestRule • onNodeWithTag • performClick / assertTextEquals 四种测试类型覆盖了Android开发的主要场景

26.6 避坑指南

最后分享几个我踩过的坑,希望能帮你少走弯路:

  1. MockK的 relaxed 模式要慎用。 如果你用 mockk<T>(relaxed = true),所有未定义的方法都会返回默认值。这看起来很省事,但容易掩盖问题。我建议默认用严格模式,只在需要时才用 relaxed
  2. Compose测试里不要用 Thread.sleepcomposeTestRule.waitForIdle() 或者 waitUntil 来等待异步操作完成。用 sleep 会让测试变慢,而且不稳定。
  3. 测试协程时记得清理 TestCoroutineDispatcher@After 方法里调用 cleanupTestCoroutines(),否则可能会影响后续测试。
  4. 不要测试私有方法。 只测试公开API。如果你觉得某个私有方法需要单独测试,说明它应该被提取成一个独立的类。

测试这件事,说白了就是「先苦后甜」。刚开始写测试时确实会觉得慢,但一旦项目变大、人员变多,测试就是你的救命稻草。我见过太多项目因为没测试,改一行代码要手动测半小时——那才是真正的浪费时间。


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