测试体系
★ 测试体系是中级 Android 面试的短板高频区。能讲清“怎么测“,比只会说 MVVM/MVI 更能证明工程能力。
一、测试金字塔与 Android 测试分层
| 层级 | 目标 | 工具 | 典型对象 |
|---|---|---|---|
| 单元测试 | 快速验证纯逻辑 | JUnit / Truth / MockK | UseCase、Repository、Reducer |
| 集成测试 | 验证多层协作 | Robolectric / fake data source | ViewModel + Repository |
| UI 测试 | 验证用户路径 | Espresso / Compose Test | 页面交互、导航、错误提示 |
原则:越靠下越多、越快、越稳定;越靠上越少、越接近真实用户。
二、单元测试:Junit、断言与 Mock
- JUnit 负责组织测试生命周期。
- Truth/AssertJ 让断言可读。
- MockK/Mockito 用于隔离外部依赖,但不要 mock 一切。
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 层。
六、可测试性如何反推架构质量
如果一个 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 单独门禁,不要混在普通单元测试里。
高频面试题
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 容易脆弱。