Android 测试全景:从 JVM 到真机
前面十九章讲的是通用道理,这一章把它们全部落到 Android 的具体工具上。Android 的测试栈,是所有平台里比较绕的一个 —— 因为你的代码要跑在一个庞大的框架上,而这个框架又只在真机/模拟器上完整存在。于是产生了一个核心张力:跑在 JVM 上快但碰不到框架,跑在真机上完整但慢。整个 Android 测试栈,本质上就是一堆在这条张力线上取不同折中的工具。这一章给你一张地图,标清每个工具站在哪、解决什么、以及你的精力该往哪投。
那条核心张力:JVM ↔ 真机
第 5 章已经点过两个 sourceSet,这里把它当作理解整个栈的坐标轴:
src/test/(JVM) | src/androidTest/(真机) | |
|---|---|---|
| 跑在 | 本地 JVM,毫秒级 | 模拟器/真机,秒级 |
| 能碰框架吗 | 碰不到真的(除非 Robolectric) | 碰得到,完整运行时 |
| 装 APK 吗 | 不用 | 要 |
| 典型内容 | 单元测试、逻辑 | Espresso UI、真机集成 |
金字塔(第 4 章)在 Android 上的落地就是:尽可能把测试往 test/ 挪(快),只有真的需要框架/UI 的,才放 androidTest/(慢)。
Robolectric:把 Android 搬进 JVM
但有大量代码卡在中间:它需要一点 Android 框架(比如 Context、SharedPreferences、资源),但又没必要为它启动整个模拟器。Robolectric 就是为这个缝生的:
它提供一套 Android 框架的 JVM 实现(shadow 对象),让你能在 test/ 里跑那些"碰了框架"的代码 —— 不用真机,快得多。第 6 章的 Room 内存库测试,通常就靠它跑。
取舍:它比纯单元测试慢(要加载框架模拟),但比真机快几个数量级。它是"我需要框架但不需要真机"这一层的答案。注意它是模拟,不是真机 —— 极少数框架行为的细节可能和真机有出入,那些才需要真正的 instrumented 测试兜底。
一张栈图:每个工具站在哪
把 Android 测试常用工具,按"范围/速度"从快到慢排一遍,你就有了完整地图:
| 层 | 工具 | 跑在 | 测什么 |
|---|---|---|---|
| 单元 | JUnit5 / kotlin.test + MockK | JVM | 纯逻辑、ViewModel(第 5、10 章) |
| 单元(异步) | coroutines-test + Turbine | JVM | 协程、Flow(第 12 章) |
| 集成 | Robolectric + 内存 Room + MockWebServer | JVM(模拟) | DAO、网络解析(第 6 章) |
| 截图 | Paparazzi / Roborazzi | JVM(渲染) | 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 测试栈
- 放错目录。它经常给一段纯逻辑写
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 实现
}
}
这段代码碰了 Context 和 SharedPreferences(真机才有的东西),但因为 Robolectric 在 JVM 上给了它们一套 shadow 实现,它不用装 APK、不用连模拟器,几百毫秒跑完。这正是「我需要一点框架,但不想为它上真机」那个中间层的答案。
1. 清点你项目 androidTest/ 里的测试,看有几个其实是纯逻辑、本该下沉到 test/。把一个搬下去,感受速度差别。
2. 如果还没有 MainDispatcherRule,封一个。它会让一大批"没法测的 ViewModel"变得可测。
3. 给一个关键 Compose 组件加一个 Roborazzi 截图测试,把"长相"也纳入 CI 的守护范围。
Android 讲完了。下一章把同一套思想在前端过一遍 —— 你会发现形状一模一样,只是换了工具:Vitest 是单元、Testing Library 是"像用户一样查询"的组件测试、Playwright 是端到端。而 Testing Library 的那句口头禅,恰好是这整本书哲学的一句话总结。