Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

测试体系

★ 测试体系是中级 Android 面试的高频考点, 也是常见短板区. 能讲清 “怎么测”, 比只会说 MVVM/MVI 更能证明工程能力.

一, 测试金字塔与 Android 测试分层

层级目标工具典型对象
单元测试快速验证纯逻辑JUnit / Truth / MockKUseCase, Repository, Reducer
集成测试验证多层协作Robolectric / fake data sourceViewModel + 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 的转换.

对象测什么不测什么
ViewModelstate/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, 让测试更接近真实流程.

依赖推荐测试替身原因
RepositoryFakeRepository可控制成功/失败/缓存命中
Clock/TimeProviderFakeClock避免真实时间导致 flake
DispatcherTestDispatcher控制协程执行和虚拟时间
Network APIMockWebServer / Fake API验证协议或业务分支

Robolectric vs Instrumentation

类型跑在哪里适合验证不适合
RobolectricJVMViewModel, 资源, 轻量 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 testJVM纯 Kotlin/Java, UseCase, ReducerPR 必跑, 快速失败
RobolectricJVM + 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 容易脆弱.