Android 测试体系:单元测试、Espresso 与 Compose 测试

测试金字塔在 Android 上有自己的形状:JVM 单测跑得快但受限于无 Android 运行时,仪器测试真实但慢且易碎。本文讲透 JUnit 与断言库选型、协程测试调度器、MockK 打桩、Robolectric 本地跑 Android 代码、Espresso 与 IdlingResource、Compose 语义查找与断言、Hilt 测试替换,以及 CI 分片与设备农场。

引言

Android 的测试比后端复杂,原因不在断言本身,而在「运行环境」这一层:JVM 单元测试快得可以每次保存都跑,却拿不到真实的 Context、资源与生命周期;仪器测试(instrumented test)跑在真机或模拟器上,环境真实但一次全量执行要十几分钟,还容易因为动画、异步与设备性能而随机失败。

于是测试策略的核心问题变成:哪些逻辑放在 JVM 上测、哪些必须上设备。答案通常是按「依赖 Android 框架的程度」划分:纯逻辑与协程、Flow 在 JVM 上测;ViewModel 用 Robolectric 或测试调度器测;涉及真实渲染、输入事件与系统交互的部分留给 Espresso 与 Compose 测试。

本文按「分层 → 单测 → 打桩 → 本地 Android 测试 → UI 测试 → 集成与 CI」的顺序展开。异步逻辑的测试会大量用到协程测试工具,其中的调度器与虚拟时间语义可对照 Kotlin 协程与结构化并发 ;与 iOS 侧测试体系的对照可参考 iOS 测试与持续集成 。


目录

  1. 测试分层与依赖配置
  2. JUnit 与断言库
  3. 协程测试与测试调度器
  4. MockK 与 Mockito 打桩
  5. Robolectric 本地测试
  6. Espresso 与 IdlingResource
  7. Compose UI 测试
  8. 测试规则与依赖注入
  9. 覆盖率与质量门禁
  10. CI 分片与设备农场
  11. 常见失败模式排查

1. 测试分层与依赖配置

先明确三种测试的物理位置与执行环境,这是后面所有选型的基础。

类型目录运行环境典型耗时适合测什么
JVM 单测src/test/本地 JVM毫秒级纯逻辑、协程、Flow、映射
Robolectricsrc/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() }
    }
}
场景MockKMockito
普通方法打桩every { } returnswhen(...).thenReturn(...)
挂起函数coEvery { } returns需 mockito-kotlin 的 whenBlocking
参数匹配every { f(any()) }ArgumentMatchers.any()
参数捕获slot<T>() + capture(slot)ArgumentCaptor
宽松 mockmockk<T>(relaxed = true)RETURNS_DEEP_STUBS
静态方法mockkStatic / mockkObjectmockStatic(需 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 + FlowJVM 单测 + 测试调度器虚拟时间可控,覆盖并发分支
Room DAORobolectric + 内存库真实 SQL 执行,无需设备
资源与 Context 逻辑RobolectricJVM 上模拟框架,速度快
Compose 交互与状态Compose 测试语义树稳定,不依赖视图 id
View 体系交互Espresso真实渲染与输入事件
跨应用跳转、权限仪器测试 + Espresso Intents必须真实系统环境
端到端主流程少量仪器测试昂贵且易碎,只留冒烟级

原则是「能用 JVM 测的就不要上设备」:一个 JVM 单测约 5 毫秒,一个仪器测试约 5 秒,差三个数量级。测试金字塔不是教条,但它背后的成本差异是真实的。


常见坑清单

坑现象规避方式
忘记 ui-test-manifestCompose 测试启动即崩加 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 测试的落地顺序可以收成四句话:

  1. 按依赖程度分层:纯逻辑走 JVM,需要框架但不需要真实渲染走 Robolectric,需要真实交互才上设备。
  2. 时间必须可控:协程用测试调度器与虚拟时间,Compose 用 mainClock 与 waitUntil,任何 sleep 都是失败信号。
  3. Fake 优先于 Mock:手写实现比打桩框架更可读、更稳定,MockK 只用于无法替换的边界。
  4. CI 的成本在设备:Gradle Managed Devices 加 aosp-atd 镜像打底,用例多了再上分片与 Orchestrator。

把这四条固定成团队的测试规范,测试套件才会随着项目增长而保持可信——一套经常误报的测试,最终一定会被所有人忽略。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Android 开发」更多文章

  1. Kotlin Multiplatform 跨平台共享
  2. Android R8 混淆与 Baseline Profile
  3. WorkManager 后台任务调度