幕 V · 落地CH 20进度 20/24

Android 测试全景:从 JVM 到真机

前面十九章讲的是通用道理,这一章把它们全部落到 Android 的具体工具上。Android 的测试栈,是所有平台里比较绕的一个 —— 因为你的代码要跑在一个庞大的框架上,而这个框架又只在真机/模拟器上完整存在。于是产生了一个核心张力:跑在 JVM 上快但碰不到框架,跑在真机上完整但慢。整个 Android 测试栈,本质上就是一堆在这条张力线上取不同折中的工具。这一章给你一张地图,标清每个工具站在哪、解决什么、以及你的精力该往哪投。

test vs androidTestRobolectricHilt 测试Compose TestMainDispatcher Rule

那条核心张力:JVM ↔ 真机

第 5 章已经点过两个 sourceSet,这里把它当作理解整个栈的坐标轴:

src/test/(JVM)src/androidTest/(真机)
跑在本地 JVM,毫秒级模拟器/真机,秒级
能碰框架吗碰不到真的(除非 Robolectric)碰得到,完整运行时
装 APK 吗不用
典型内容单元测试、逻辑Espresso UI、真机集成

金字塔(第 4 章)在 Android 上的落地就是:尽可能把测试往 test/(快),只有真的需要框架/UI 的,才放 androidTest/(慢)。

Robolectric:把 Android 搬进 JVM

但有大量代码卡在中间:它需要一点 Android 框架(比如 ContextSharedPreferences、资源),但又没必要为它启动整个模拟器。Robolectric 就是为这个缝生的:

◆ 判词 · Robolectric = 在 JVM 上模拟一个 Android

它提供一套 Android 框架的 JVM 实现(shadow 对象),让你能在 test/ 里跑那些"碰了框架"的代码 —— 不用真机,快得多。第 6 章的 Room 内存库测试,通常就靠它跑。

取舍:它比纯单元测试慢(要加载框架模拟),但比真机快几个数量级。它是"我需要框架但不需要真机"这一层的答案。注意它是模拟,不是真机 —— 极少数框架行为的细节可能和真机有出入,那些才需要真正的 instrumented 测试兜底。

一张栈图:每个工具站在哪

把 Android 测试常用工具,按"范围/速度"从快到慢排一遍,你就有了完整地图:

工具跑在测什么
单元JUnit5 / kotlin.test + MockKJVM纯逻辑、ViewModel(第 5、10 章)
单元(异步)coroutines-test + TurbineJVM协程、Flow(第 12 章)
集成Robolectric + 内存 Room + MockWebServerJVM(模拟)DAO、网络解析(第 6 章)
截图Paparazzi / RoborazziJVM(渲染)Compose 长相(第 14 章)
UI / 端到端Compose Test / Espresso真机用户旅程(第 7 章)

注意大部分工具都在 JVM 那侧 —— 这是好事。你能把绝大多数验证放在快的一侧,只把真正需要真机的 UI 旅程留给慢的一侧。

Hilt:让测试能换掉依赖

要想 fake 掉依赖(第 10 章),前提是能注入。用了 Hilt 的项目,测试里可以整体替换某个模块:

@HiltAndroidTest
class LoginTest {
    @get:Rule val hilt = HiltAndroidRule(this)

    @BindValue @JvmField                       // 用 fake 替换真的 UserRepo
    val userRepo: UserRepo = FakeUserRepo()

    @Test fun `登录`() { /* 注入的是 fake,不碰真网络 */ }
}

@BindValue 让你在测试里把某个真依赖换成 fake/mock,而应用其余部分的接线保持不变。这是"在架构边界上替身"(第 11 章)在 Android 上的标准做法。

两个必备的 Rule

有两个 JUnit Rule,几乎每个 Android 测试项目都会封装:

// 1) 替换 Dispatchers.Main —— 纯 JVM 测试里没有真的主线程(第 12 章)
class MainDispatcherRule : TestWatcher() {
    private val dispatcher = StandardTestDispatcher()
    override fun starting(d: Description) { Dispatchers.setMain(dispatcher) }
    override fun finished(d: Description) { Dispatchers.resetMain() }
}

// 2) Compose 测试规则
@get:Rule val compose = createComposeRule()   // 或 createAndroidComposeRule<T>()

第一个解决"JVM 里没有主线程调度器"的问题;没有它,任何用了 viewModelScope(它跑在 Main 上)的 ViewModel 测试都会崩。第二个给你 compose.onNodeWith... 那套 API。把它们封成可复用的 Rule,是每个 Android 测试基建的第一块砖。

该往哪层投精力

◆ 判词 · 精力分配,跟着"速度/信心"的性价比走
  • 大头(60–70%):JVM 单元测试。ViewModel、UseCase、纯逻辑、Flow。又快又能穷尽边界,是你最该重投的地方。
  • 中间(20–30%):Robolectric 集成 + 截图。DAO、网络解析、关键组件的长相。测那些"单元看不见的接缝"。
  • 塔尖(5–10%):真机 UI 端到端。只覆盖几条 P0 旅程(登录、核心功能)。慢、脆、诊断差,少而精。

一句话:能在 JVM 测的,绝不上真机。这既是速度(第 4 章),也是测试能不能被频繁跑、进而能不能活下去的关键。

AI 与 Android 测试栈

▲ AI 最常搞错的三件事
  • 放错目录。它经常给一段纯逻辑写 androidTest/ 里的 instrumented 测试(慢、要真机),而这本该是 test/ 里的毫秒级单元测试。审查时看它把测试放哪、用没用真机。
  • 漏掉 MainDispatcherRule。它写的 ViewModel 协程测试常常忘了替换 Main 调度器,在你的 CI 上直接崩(Module with the Main dispatcher had failed to initialize)。
  • 版本/API 张冠李戴。Android 测试库版本迭代快,AI 容易把旧 API(JUnit4 的写法、过时的 coroutines-test API)和新的混着用。跑一遍是唯一的确认 —— 又一次,别信它"看起来对"。

配置一眼:测试依赖分两组

Android 的测试依赖,在 build.gradle.kts 里也分成对应的两组,别放错:

dependencies {
    // testImplementation:JVM 单元测试(src/test/)
    testImplementation("org.jetbrains.kotlin:kotlin-test")
    testImplementation("io.mockk:mockk:...")
    testImplementation("app.cash.turbine:turbine:...")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:...")
    testImplementation("org.robolectric:robolectric:...")   // 想在 JVM 上碰框架

    // androidTestImplementation:真机测试(src/androidTest/)
    androidTestImplementation("androidx.test.ext:junit:...")
    androidTestImplementation("androidx.compose.ui:ui-test-junit4:...")
    androidTestImplementation("androidx.test.espresso:espresso-core:...")
}

一个常见的困惑:MockK 有个专门的 mockk-android artifact,给 androidTest 用;而纯 JVM 测试用普通的 mockk。放错分组会出现「本地能跑、CI 上找不到类」这类怪事。

Robolectric 一眼:JVM 上跑「像真机」的测试

看它长什么样,你就懂了它站在栈的哪个位置 —— 写起来像真机测试(有 Context、能 launch Activity),却跑在 test/ 的 JVM 上:

@RunWith(AndroidJUnit4::class)   // 在 test/ 里,这会触发 Robolectric
class SharedPrefsRepoTest {
    private val context = ApplicationProvider.getApplicationContext<Context>()

    @Test fun `存了 token 能读回来`() {
        val repo = SharedPrefsRepo(context)   // 用了真的 SharedPreferences API
        repo.saveToken("abc")
        assertEquals("abc", repo.token())     // Robolectric 提供了 shadow 实现
    }
}

这段代码碰了 ContextSharedPreferences(真机才有的东西),但因为 Robolectric 在 JVM 上给了它们一套 shadow 实现,它不用装 APK、不用连模拟器,几百毫秒跑完。这正是「我需要一点框架,但不想为它上真机」那个中间层的答案。

✚ 动手

1. 清点你项目 androidTest/ 里的测试,看有几个其实是纯逻辑、本该下沉到 test/。把一个搬下去,感受速度差别。

2. 如果还没有 MainDispatcherRule,封一个。它会让一大批"没法测的 ViewModel"变得可测。

3. 给一个关键 Compose 组件加一个 Roborazzi 截图测试,把"长相"也纳入 CI 的守护范围。

Android 讲完了。下一章把同一套思想在前端过一遍 —— 你会发现形状一模一样,只是换了工具:Vitest 是单元、Testing Library 是"像用户一样查询"的组件测试、Playwright 是端到端。而 Testing Library 的那句口头禅,恰好是这整本书哲学的一句话总结。