测试体系
★ 测试体系是中级 Android 面试的高频考点, 也是常见短板区. 能讲清 “怎么测”, 比只会说 MVVM/MVI 更能证明工程能力.
一, 测试金字塔与 Android 测试分层
| 层级 | 目标 | 工具 | 典型对象 |
|---|---|---|---|
| 单元测试 | 快速验证纯逻辑 | JUnit / Truth / MockK | UseCase, Repository, Reducer |
| 集成测试 | 验证多层协作 | Robolectric / fake data source | ViewModel + Repository |
| UI 测试 | 验证用户路径 | Espresso / Compose Test | 页面交互, 导航, 错误提示 |
原则: 越靠下越多, 越快, 越稳定; 越靠上越少, 越接近真实用户.
金字塔成立的前提是架构可测: 逻辑下沉到纯 JVM 层, 依赖可注入替换; MVP/MVVM 的可测性原理见 应用架构 的「MVP Presenter 的最小测试策略」. 本篇聚焦测试方法与工具.
二, 单元测试: JUnit, 断言与 Mock
- JUnit 负责组织测试生命周期.
- Truth/AssertJ 让断言可读.
- MockK/Mockito 用于隔离外部依赖, 但不要 mock 一切.
- MockK 常见坑: 挂起函数要用
coEvery/coVerify, 直接every/verify匹配不上;relaxed = true会对未 stub 的调用静默返回默认值, 掩盖漏 stub 的调用, 属反模式.
class LoginUseCaseTest {
@Test
fun `blank username returns validation error`() {
val useCase = LoginUseCase(fakeRepository)
val result = useCase.execute(username = "", password = "123456")
assertThat(result).isEqualTo(LoginResult.InvalidUsername)
}
}
三, 协程与 Flow 测试
- 用
runTest控制虚拟时间. - 用
StandardTestDispatcher替换真实 dispatcher. - Flow 可用 Turbine 验证 emit 顺序.
@Test
fun `flow emits loading then success`() = runTest {
repository.userFlow().test {
assertThat(awaitItem()).isEqualTo(UiState.Loading)
assertThat(awaitItem()).isInstanceOf(UiState.Success::class.java)
awaitComplete()
}
}
四, ViewModel / Repository 怎么测
ViewModel 测试重点不是测试 Android 框架, 而是测试输入事件到 UI State 的转换.
| 对象 | 测什么 | 不测什么 |
|---|---|---|
| ViewModel | state/effect, 错误处理, 重试 | 具体控件绘制 |
| Repository | 缓存策略, 数据源切换 | Retrofit/Room 本身 |
| UseCase | 业务规则 | 外部 IO |
五, UI 测试: Espresso 与 Compose Test
- Espresso 适合 View 体系页面.
- Compose Test 适合声明式 UI, 优先通过 semantic matcher 找节点.
- UI 测试要覆盖核心路径, 不要把所有边界都堆在 UI 层.
- 现状定位: Compose UI Test 与 Robolectric 仍是主流工具链, 具体 API 行为以当前版本为准.
可运行示例 (instrumented test): 以下代码放在 src/androidTest. 前提是模块配置 AndroidJUnitRunner, androidx.test.ext:junit, Espresso 依赖, LoginActivity 使用 R.id.login_button, 并用 fake/IdlingResource 消除异步网络. 运行到设备/模拟器后, 预期按钮可见且点击成功; 不能依赖 sleep.
class LoginActivityTest {
@get:Rule val scenarioRule = ActivityScenarioRule(LoginActivity::class.java)
@Test fun loginButton_isClickable() {
onView(withId(R.id.login_button)).check(matches(isDisplayed())).perform(click())
}
}
可运行示例 (Compose instrumented test): 前提是 Compose UI test 依赖与 AndroidJUnitRunner; 下例内联被测组件, 测试 rule 来自 createComposeRule(). 预期点击后回调被调用一次.
@get:Rule val composeRule = createComposeRule()
@Composable fun SubmitButton(modifier: Modifier = Modifier, onSubmit: () -> Unit) {
Button(onClick = onSubmit, modifier = modifier) { Text("提交") }
}
@Test fun submit_invokesCallback() { var calls = 0
composeRule.setContent { SubmitButton(Modifier.testTag("submit")) { calls++ } }
composeRule.onNodeWithTag("submit").performClick()
composeRule.runOnIdle { assertThat(calls).isEqualTo(1) }
}
可运行示例 (Robolectric JVM test): 适用于依赖资源或轻量 Android 生命周期的代码, 不证明真机权限弹窗. 启动 Activity 后预期标题资源被正确解析.
@RunWith(RobolectricTestRunner::class)
class MainActivityTest {
@Test fun title_isShown() {
val activity = Robolectric.buildActivity(MainActivity::class.java).setup().get()
assertThat(activity.title).isEqualTo("首页")
}
}
六, 可测试性如何反推架构质量
如果一个 ViewModel 很难测, 通常说明它持有太多 Android 依赖, 业务逻辑没有下沉, 状态 / 副作用没有分离.
七, Android 测试 Harness: 让测试稳定, 可重复
中级面试不只问 “会不会写 JUnit”, 更常追问 “为什么你的测试稳定”.核心是把时间, 线程, 数据源和 Android 框架依赖都收口.
MainDispatcherRule 与协程调度
ViewModel 默认使用 Dispatchers.Main, 本地单元测试里没有真实 Main Looper, 需要在测试规则里替换.
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
private val dispatcher: TestDispatcher = StandardTestDispatcher()
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}
面试表达: 生产代码不要在业务逻辑里硬编码 Dispatchers.IO/Main, 可以通过 DispatcherProvider 注入, 测试时替换成 TestDispatcher.
Fake 优先于过度 Mock
Mock 适合验证交互, Fake 更适合表达业务状态. Repository/DataSource 可以写内存 fake, 让测试更接近真实流程.
| 依赖 | 推荐测试替身 | 原因 |
|---|---|---|
| Repository | FakeRepository | 可控制成功/失败/缓存命中 |
| Clock/TimeProvider | FakeClock | 避免真实时间导致 flake |
| Dispatcher | TestDispatcher | 控制协程执行和虚拟时间 |
| Network API | MockWebServer / Fake API | 验证协议或业务分支 |
Robolectric vs Instrumentation
| 类型 | 跑在哪里 | 适合验证 | 不适合 |
|---|---|---|---|
| Robolectric | JVM | ViewModel, 资源, 轻量 Android API | 真机硬件, 厂商 ROM, 系统权限弹窗 |
| Instrumentation | 设备 / 模拟器 | UI 交互, 权限, 真实生命周期 | 大量边界逻辑单测 |
Flaky Test 治理
- 不用
Thread.sleep等待异步, 改用虚拟时间, IdlingResource, Compose test clock. - UI 测试用稳定语义节点, 不依赖文案频繁变化或 RecyclerView 位置.
- 每个测试自己准备数据并清理, 不依赖执行顺序.
- CI 对 flaky 用隔离重跑和标记治理, 不能简单 “失败就 rerun 到绿”.
CI 质量门禁
PR 阶段至少跑快速单元测试, lint 和关键模块测试; 夜间或合并后跑更慢的 UI / 端到端测试. 性能回归用 Macrobenchmark 单独门禁, 不要混在普通单元测试里.
八, 测试类型与 CI 运行条件
| 类型 | 运行环境 | 主要验证 | CI 建议 |
|---|---|---|---|
| Local unit test | JVM | 纯 Kotlin/Java, UseCase, Reducer | PR 必跑, 快速失败 |
| Robolectric | JVM + Android shadow | 资源, 轻量生命周期和 Android API 协作 | PR 或模块级门禁; 不替代真机 |
| Instrumented test | 模拟器 / 真机 | Framework, 权限, 数据库, 进程和设备行为 | 关键路径在合并前或设备农场运行 |
| Compose/Espresso UI | 模拟器/真机 | 用户交互, 语义, 导航和恢复 | 核心旅程; 控制数量和 flake |
| Macrobenchmark | 独立 benchmark module/设备 | 启动, 帧, 滚动等性能分布 | 稳定设备的夜间/发布门禁, 与普通单测分开 |
测试报告必须说明设备/API, 构建类型, 是否使用 mock/fake, 重试情况和未覆盖项. “Robolectric 通过” 不能证明厂商 ROM, 系统权限弹窗或真实性能正确.
高频面试题
Q1: 你项目里怎么做测试分层? 答: 核心业务规则放单元测试, ViewModel/Repository 做集成测试, 关键用户路径做 UI 测试. 比例上单元测试最多, UI 测试最少.
Q2: 为什么 Repository 不应该直接测 Retrofit/Room? 答: Retrofit/Room 是框架能力, 业务测试应关注 Repository 的缓存, 错误兜底, 数据源切换. 框架集成可少量用 integration test 验证.
Q3: 协程测试为什么不用真实 delay?
答: 真实 delay 会让测试慢且不稳定; runTest 用虚拟时间推进, 可稳定验证超时, 重试, debounce 等逻辑.
Q4: 怎么让 Android 测试稳定不 flaky?
答: 把不可控因素收口. 协程用 runTest 和 TestDispatcher, 时间用 FakeClock, 网络用 Fake/MockWebServer, UI 用稳定 semantic matcher 或 IdlingResource, 测试数据每次独立准备. CI 上把快测和慢测分层, 失败要定位原因, 不能靠无限重跑掩盖问题.
易错点 / 追问
- 不要为了覆盖率 mock 所有东西, 那会测到实现细节.
- UI 测试不要依赖真实网络和随机数据.
- Compose 测试要给关键节点加稳定语义, 否则 matcher 容易脆弱.