引言
Android 的测试比后端复杂,原因不在断言本身,而在「运行环境」这一层:JVM 单元测试快得可以每次保存都跑,却拿不到真实的 Context、资源与生命周期;仪器测试(instrumented test)跑在真机或模拟器上,环境真实但一次全量执行要十几分钟,还容易因为动画、异步与设备性能而随机失败。
于是测试策略的核心问题变成:哪些逻辑放在 JVM 上测、哪些必须上设备。答案通常是按「依赖 Android 框架的程度」划分:纯逻辑与协程、Flow 在 JVM 上测;ViewModel 用 Robolectric 或测试调度器测;涉及真实渲染、输入事件与系统交互的部分留给 Espresso 与 Compose 测试。
本文按「分层 → 单测 → 打桩 → 本地 Android 测试 → UI 测试 → 集成与 CI」的顺序展开。异步逻辑的测试会大量用到协程测试工具,其中的调度器与虚拟时间语义可对照 Kotlin 协程与结构化并发 ;与 iOS 侧测试体系的对照可参考 iOS 测试与持续集成 。
目录
- 测试分层与依赖配置
- JUnit 与断言库
- 协程测试与测试调度器
- MockK 与 Mockito 打桩
- Robolectric 本地测试
- Espresso 与 IdlingResource
- Compose UI 测试
- 测试规则与依赖注入
- 覆盖率与质量门禁
- CI 分片与设备农场
- 常见失败模式排查
1. 测试分层与依赖配置
先明确三种测试的物理位置与执行环境,这是后面所有选型的基础。
| 类型 | 目录 | 运行环境 | 典型耗时 | 适合测什么 |
|---|---|---|---|---|
| JVM 单测 | src/test/ | 本地 JVM | 毫秒级 | 纯逻辑、协程、Flow、映射 |
| Robolectric | src/test/ | 本地 JVM + 影子框架 | 百毫秒级 | ViewModel、资源、SQLite |
| 仪器测试 | src/androidTest/ | 真机 / 模拟器 | 秒到分钟级 | UI 交互、真实渲染、系统集成 |
依赖按需引入,全部堆进去会显著拖慢构建。JVM 侧需要 junit:junit:4.13.2、io.mockk:mockk:1.13.13、kotlinx-coroutines-test:1.9.0、app.cash.turbine:turbine:1.1.0、org.robolectric:robolectric:4.14、com.google.truth:truth:1.4.4;仪器侧需要 androidx.test.ext:junit:1.2.1、espresso-core:3.6.1、espresso-intents:3.6.1,以及 Compose BOM 下的 ui-test-junit4:
androidTestImplementation(platform("androidx.compose:compose-bom:2024.09.00"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest") // createComposeRule 需要
ui-test-manifest 必须是 debugImplementation:它为 createComposeRule() 提供一个空的测试 Activity 宿主,缺了它 Compose 测试会直接抛 ActivityNotFoundException。
2. JUnit 与断言库
JUnit 4 仍是 Android 的事实标准(JUnit 5 在 Android 上的支持依赖第三方插件,AGP 原生支持仍以 4 为主)。测试方法命名建议用反引号写中文或完整句子,失败时的可读性远高于 testFoo1:
class PriceFormatterTest {
@Test
fun `低于一元时显示两位小数`() {
assertEquals("0.99", format(0.99))
}
@Test(expected = IllegalArgumentException::class)
fun `负数金额抛异常`() {
format(-1.0)
}
}
断言库的选择上,Kotlin 项目推荐 Truth 或 Kotest 断言,因为它们给出的失败信息包含「期望值、实际值、差异」:
import com.google.common.truth.Truth.assertThat
assertThat(articles).hasSize(3)
assertThat(articles[0].title).isEqualTo("Kotlin 协程")
assertEquals 在集合比较失败时只打印两个 toString(),元素多时几乎无法定位;Truth 会指出第几个元素不同。这是低成本高回报的改进。
@Before / @After 用于准备与清理,@get:Rule 用于需要包裹整个测试生命周期的资源(临时目录、调度器替换、Hilt 注入)。多个 Rule 有执行顺序问题,用 @get:Rule(order = 0) 显式声明,顺序号小的先执行。
3. 协程测试与测试调度器
协程测试的核心是「用虚拟时间替换真实时间」。runTest 会跳过 delay,因此一个包含 delay(10_000) 的测试能在几毫秒内跑完。
@Test
fun `搜索去抖 300ms 后才触发`() = runTest {
val vm = SearchViewModel(FakeRepo(), StandardTestDispatcher(testScheduler))
vm.onQueryChanged("kotlin")
advanceTimeBy(299)
assertThat(vm.results.value).isEmpty() // 还没到 300ms
advanceTimeBy(2)
assertThat(vm.results.value).hasSize(3)
}
三个关键点:
runTest内部使用TestScope,delay走虚拟时钟,advanceTimeBy/advanceUntilIdle手动推进。- 必须替换
Dispatchers.Main。JVM 上没有 Android 主线程,Dispatchers.setMain(StandardTestDispatcher(testScheduler))是必需步骤,否则viewModelScope一启动就抛Module with the Main dispatcher had failed to initialize。 StandardTestDispatcher与UnconfinedTestDispatcher语义不同:前者不自动执行,需要显式推进(更贴近真实调度,推荐默认使用);后者立即执行,写起来省事但会掩盖顺序问题。
class MainDispatcherRule(
private val dispatcher: TestDispatcher = StandardTestDispatcher(),
) : TestWatcher() {
override fun starting(d: Description) = Dispatchers.setMain(dispatcher)
override fun finished(d: Description) = Dispatchers.resetMain()
}
// 用例中:@get:Rule val mainDispatcherRule = MainDispatcherRule()
把这个 Rule 抽出来复用,是 Android 协程测试的标准做法。
4. MockK 与 Mockito 打桩
Kotlin 项目优先选 MockK:它原生支持 final 类、object 单例、suspend 函数与协程,而 Mockito 需要 mockito-inline 才能 mock final 类,suspend 函数还要配 mockito-kotlin 才能写得顺手。
class ArticleViewModelTest {
private val repo = mockk<ArticleRepository>()
@Test
fun `加载失败时展示错误态`() = runTest {
coEvery { repo.observeAll() } returns flow { throw IOException("boom") }
val vm = ArticleViewModel(repo, StandardTestDispatcher(testScheduler))
advanceUntilIdle()
assertThat(vm.state.value).isInstanceOf(UiState.Error::class)
coVerify(exactly = 1) { repo.observeAll() }
}
}
| 场景 | MockK | Mockito |
|---|---|---|
| 普通方法打桩 | every { } returns | when(...).thenReturn(...) |
| 挂起函数 | coEvery { } returns | 需 mockito-kotlin 的 whenBlocking |
| 参数匹配 | every { f(any()) } | ArgumentMatchers.any() |
| 参数捕获 | slot<T>() + capture(slot) | ArgumentCaptor |
| 宽松 mock | mockk<T>(relaxed = true) | RETURNS_DEEP_STUBS |
| 静态方法 | mockkStatic / mockkObject | mockStatic(需 inline) |
两点实践建议:
- 优先 Fake,而不是 Mock。手写一个
FakeArticleRepository用内存MutableStateFlow实现接口,行为可控、可读性高、不依赖打桩框架。只有当被测对象依赖外部 SDK 且无法替换实现时才用 Mock。 relaxed = true是双刃剑。它让未打桩的调用返回默认值,省去大量样板,但也让「忘记打桩」变成静默的默认值而非失败。建议只在配置对象(如Json、Clock)上使用。
5. Robolectric 本地测试
Robolectric 在 JVM 上模拟 Android 框架,让「需要 Context、资源、Looper、SQLite」的测试不必上设备。它的执行速度比仪器测试快一到两个数量级,是 ViewModel、资源读取与 Room 内存库测试的首选。
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ArticleDaoTest {
private lateinit var db: AppDatabase
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
.allowMainThreadQueries().build()
}
@After fun tearDown() = db.close()
@Test
fun `插入后查询能读到数据`() = runTest {
db.articleDao().upsert(ArticleEntity(1, "Kotlin"))
assertThat(db.articleDao().observeAll().first()).hasSize(1)
}
}
testOptions 里有两项必须开启:unitTests.isIncludeAndroidResources = true(Robolectric 需要真实资源)与 unitTests.isReturnDefaultValues = true(未 mock 的 Android API 返回默认值而非抛异常)。SDK 版本通过 @Config(sdk = [34]) 或 src/test/resources/robolectric.properties 里的 sdk=34 指定——默认使用最新支持的 SDK,与项目 targetSdk 不一致时会出现难以理解的行为差异,显式固定更稳妥。
Robolectric 的边界也要清楚:它不渲染真实像素、不执行真实动画、不覆盖厂商 ROM 的行为差异。把 UI 视觉与手势相关断言放给它,会得到「本地过、真机挂」的假信心。
6. Espresso 与 IdlingResource
Espresso 用「查找 → 操作 → 断言」三段式写 UI 测试,ActivityScenario 负责启动被测页面:
@RunWith(AndroidJUnit4::class)
class LoginActivityTest {
@Test
fun `输入正确凭据后进入首页`() {
ActivityScenario.launch(LoginActivity::class.java).use {
onView(withId(R.id.username)).perform(typeText("leeting"), closeSoftKeyboard())
onView(withId(R.id.submit)).perform(click())
onView(withId(R.id.home_root)).check(matches(isDisplayed()))
}
}
}
Espresso 的同步机制依赖「主线程空闲」这个信号,因此当异步任务跑在自建线程池或 Handler 之外时,Espresso 无法感知,测试就会在数据到达之前断言。解决办法是注册 CountingIdlingResource:在被测代码的异步开始与结束时分别 increment() / decrement(),测试侧 IdlingRegistry.getInstance().register(...)。实践上更推荐用「可观察状态」来规避:把网络与数据库替换成同步的 Fake,让 UI 的数据来源是 StateFlow 而非回调,Espresso 就能靠主线程空闲正确同步。IdlingResource 应作为最后手段,因为它把测试与生产代码耦合在一起。
espresso-intents 用于校验页面发起的 Intent,例如验证「点击分享后打开了正确的目标」:
Intents.init()
onView(withId(R.id.share)).perform(click())
intended(hasAction(Intent.ACTION_SEND))
Intents.release()
7. Compose UI 测试
Compose 测试通过语义树(semantics tree)而非视图树来查找节点,因此比 Espresso 更稳定:它不依赖布局层级与视图 id。
@get:Rule val rule = createComposeRule()
@Test
fun `点击条目回调携带正确 id`() {
var clicked: Long? = null
rule.setContent {
ArticleList(items = listOf(Article(1, "Kotlin")), onClick = { clicked = it })
}
rule.onNodeWithText("Kotlin").performClick()
rule.runOnIdle { assertThat(clicked).isEqualTo(1L) }
}
查找器与断言是核心 API:
| 查找器 | 用途 |
|---|---|
onNodeWithText("提交") | 按可见文本查找 |
onNodeWithTag("submit") | 按 Modifier.testTag 查找 |
onNodeWithContentDescription("关闭") | 按无障碍描述查找 |
onAllNodesWithTag("item") | 返回节点集合,可 assertCountEquals(3) |
onNodeWithTag("list", useUnmergedTree = true) | 穿透合并语义树查找 |
rule.onAllNodesWithTag("item").assertCountEquals(3)
rule.onNodeWithTag("list").performScrollToIndex(10)
rule.onNodeWithText("加载中").assertDoesNotExist()
rule.onNodeWithTag("submit").assertIsNotEnabled()
三个容易踩的点:
Modifier.testTag是必需的前置。没有它就只能按文本查找,而文本会随多语言变化。- 合并语义树(merged tree)会隐藏子节点。
Row会把子元素的文本合并到一个节点上,需要单独断言子节点时加useUnmergedTree = true。 mainClock控制时间。Compose 测试默认自动推进时钟(autoAdvance = true),要断言动画中间态就设mainClock.autoAdvance = false,再用mainClock.advanceTimeBy(500)精确控制。
rule.mainClock.autoAdvance = false
rule.onNodeWithTag("fab").performClick()
rule.mainClock.advanceTimeBy(150)
rule.onNodeWithTag("fab").assertIsDisplayed()
异步数据到达时用 rule.waitUntilExactlyOneExists(hasTestTag("content"), timeoutMillis = 5_000) 而不是 Thread.sleep——它内部轮询语义树并推进时钟,比固定睡眠可靠得多。Compose 测试与重组行为的关系可对照 Compose 状态管理与重组优化
,理解为什么「状态写入后需要一帧才反映到语义树」。
8. 测试规则与依赖注入
仪器测试中的 Hilt 替换用 @TestInstallIn,声明式地把生产模块换成测试模块,不改一行生产代码:
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [RepositoryModule::class],
)
abstract class FakeRepositoryModule {
@Binds
abstract fun bindRepository(impl: FakeArticleRepository): ArticleRepository
}
@HiltAndroidTest
class ArticleListTest {
@get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1) val composeRule = createAndroidComposeRule<MainActivity>()
@Before fun setUp() = hiltRule.inject()
}
order = 0 必须给 HiltAndroidRule:它要先完成注入,后面的 Compose Rule 才能在已注入的 Activity 上运行。testInstrumentationRunner 也要指向自定义的 HiltTestRunner(继承 AndroidJUnitRunner 并返回 HiltTestApplication),忘了这一步的典型症状是 @AndroidEntryPoint 的 Activity 在测试中抛 Hilt Activity must be attached to an @HiltAndroidApp Application。Hilt 的模块替换机制详见 Android 依赖注入与 Hilt
。
9. 覆盖率与质量门禁
Jacoco 可以同时统计 JVM 单测与仪器测试的覆盖率,需要在 debug 构建类型上开启 enableUnitTestCoverage 与 enableAndroidTestCoverage,合并两份报告才有意义(单测侧还要配 JacocoTaskExtension 的 isIncludeNoLocationClasses = true,否则内联类会被漏掉)。
覆盖率只应作为「发现未被测试的模块」的雷达,不应作为硬性指标:强制 80% 会催生大量只调用不 assert 的测试。更有价值的门禁是:
- 关键模块的行覆盖率下限(如
data层、金额计算),而不是全项目平均值。 - 新增代码的覆盖率,配合
diff-cover只对改动部分设阈值。 - 测试执行时长上限,单测超过 60 秒就要拆分或改用 Robolectric 之外的手段。
10. CI 分片与设备农场
仪器测试的执行时间是 CI 的主要瓶颈,加速手段有三层。
第一层:按模块并行。 Gradle 的多模块任务天然并行,用 --parallel 与配置缓存把机器吃满。注意 connectedAndroidTest 会争抢设备,多模块同时跑仪器测试需要设备池。
第二层:Gradle Managed Devices。 让 Gradle 自己创建与管理模拟器,声明式配置、可缓存,本地与 CI 用同一套定义:
android {
testOptions {
managedDevices {
localDevices {
create("pixel2api34") {
device = "Pixel 2"
apiLevel = 34
systemImageSource = "aosp-atd" // ATD 镜像无 GUI,启动快
}
}
}
}
}
执行 ./gradlew pixel2api34DebugAndroidTest 即可,aosp-atd 镜像比标准镜像启动快约 40%,对 UI 测试足够。
第三层:分片与设备农场。 测试数量大时用分片把用例切到多台设备:
gcloud firebase test android run \
--type instrumentation \
--app app/build/outputs/apk/debug/app-debug.apk \
--test app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
--num-uniform-shards=4 \
--device model=Pixel2,version=30,locale=zh_CN,orientation=portrait
--num-uniform-shards=4 会把用例均匀切成四份并行执行,四台设备把墙钟时间压到约四分之一。Android Test Orchestrator 则解决另一个问题——每个用例跑在独立的 Instrumentation 进程中,避免用例间的静态状态污染。开启方式是 testOptions { execution = "ANDROIDX_TEST_ORCHESTRATOR" } 加上 androidTestUtil("androidx.test:orchestrator:1.5.1") 依赖。代价是每个用例都要重启进程,整体时间增加约 20%~30%,在测试有互相污染问题时这笔投入很值。
11. 常见失败模式排查
UI 测试的失败大多是「时序」问题而非逻辑问题。按现象定位:
| 现象 | 原因 | 对策 |
|---|---|---|
NoMatchingViewException | 视图尚未渲染 | 用 IdlingResource 或改断言为 waitUntil |
AppNotIdleException | 有持续运行的动画或轮询 | 关闭动画,检查 Choreographer 回调 |
Compose assertIsDisplayed 偶发失败 | 时钟未推进 | waitUntilExactlyOneExists |
单测 Main dispatcher 报错 | 未替换 Dispatchers.Main | 加 MainDispatcherRule |
MockKException: no answer found | 忘记打桩 | 检查 every 是否覆盖该调用 |
| 真机过、本地挂(或反之) | SDK 版本差异 | @Config(sdk = [...]) 显式固定 |
| 用例单独跑过、一起跑挂 | 静态状态污染 | 用 Test Orchestrator 或清理单例 |
| 测试耗时突然翻倍 | 新增了真实网络/磁盘访问 | 检查是否有 Fake 未生效 |
CI 上还应统一关闭动画,否则模拟器性能波动会放大时序问题:
adb shell settings put global window_animation_scale 0
adb shell settings put global transition_animation_scale 0
adb shell settings put global animator_duration_scale 0
权衡取舍
| 被测对象 | 推荐层级 | 理由 |
|---|---|---|
| 纯函数、映射、格式化 | JVM 单测 | 无依赖,毫秒级,最快反馈 |
ViewModel + Flow | JVM 单测 + 测试调度器 | 虚拟时间可控,覆盖并发分支 |
| Room DAO | Robolectric + 内存库 | 真实 SQL 执行,无需设备 |
资源与 Context 逻辑 | Robolectric | JVM 上模拟框架,速度快 |
| Compose 交互与状态 | Compose 测试 | 语义树稳定,不依赖视图 id |
| View 体系交互 | Espresso | 真实渲染与输入事件 |
| 跨应用跳转、权限 | 仪器测试 + Espresso Intents | 必须真实系统环境 |
| 端到端主流程 | 少量仪器测试 | 昂贵且易碎,只留冒烟级 |
原则是「能用 JVM 测的就不要上设备」:一个 JVM 单测约 5 毫秒,一个仪器测试约 5 秒,差三个数量级。测试金字塔不是教条,但它背后的成本差异是真实的。
常见坑清单
| 坑 | 现象 | 规避方式 |
|---|---|---|
忘记 ui-test-manifest | Compose 测试启动即崩 | 加 debugImplementation |
未替换 Dispatchers.Main | 单测抛调度器未初始化 | 加 MainDispatcherRule |
用 UnconfinedTestDispatcher 掩盖顺序 | 并发 bug 测不出来 | 默认用 StandardTestDispatcher |
| 全程 Mock 无 Fake | 测试与实现强耦合 | 优先手写 Fake |
relaxed = true 用得太广 | 漏打桩变成静默默认值 | 仅用于配置类 |
用 Thread.sleep 等异步 | 慢且不稳定 | waitUntil / advanceUntilIdle |
| 只按文本查找 Compose 节点 | 改文案就挂 | 加 Modifier.testTag |
| 忽略合并语义树 | 子节点断言找不到 | useUnmergedTree = true |
HiltAndroidRule 顺序不对 | 注入发生在 UI 启动后 | order = 0 |
| Robolectric SDK 未固定 | 本地与 CI 行为不一致 | @Config(sdk = [34]) |
| 覆盖率当硬指标 | 催生无断言的测试 | 只对关键模块设阈值 |
| 仪器测试不开分片 | CI 排队十几分钟 | Gradle Managed Devices + 分片 |
小结
Android 测试的落地顺序可以收成四句话:
- 按依赖程度分层:纯逻辑走 JVM,需要框架但不需要真实渲染走 Robolectric,需要真实交互才上设备。
- 时间必须可控:协程用测试调度器与虚拟时间,Compose 用
mainClock与waitUntil,任何sleep都是失败信号。 - Fake 优先于 Mock:手写实现比打桩框架更可读、更稳定,MockK 只用于无法替换的边界。
- CI 的成本在设备:Gradle Managed Devices 加
aosp-atd镜像打底,用例多了再上分片与 Orchestrator。
把这四条固定成团队的测试规范,测试套件才会随着项目增长而保持可信——一套经常误报的测试,最终一定会被所有人忽略。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。