关于 applying-testing-strategies
面向 Android 团队的测试策略技能,帮助组织确定性、结构清晰的测试,并配置基于 Hilt 的依赖替换。它区分文档中的指导与团队约定,并介绍跨测试使用 Fake 的方式。
主要功能
- 01归纳测试不确定性的常见来源
- 02整理测试指导的文档来源
- 03说明 Given-When-Then 与命名约定
- 04介绍 Hilt 测试规则及顺序
- 05比较 Hilt 绑定替换方式
适用场景
- 01为新测试模块建立约定
- 02排查 CI 上间歇性失败的测试
- 03在 UI 测试中用 Fake 替换生产依赖
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill applying-testing-strategies安装后在 Claude Code 中输入/applying-testing-strategies确认已加载。
详细介绍
应用测试策略 — 确定性、结构与 Hilt
测试套件要实现规模化,需要具备三点:它必须是确定的(相同输入 → 相同结果)、结构清晰的(任何读者都能找到 Given/When/Then),并且可替换的(只需替换一个绑定,就能将生产依赖图改接到伪实现)。这项技能整合了 Google 的策略指南——明确指出每条规则实际来自哪个页面——以及将其接入构建的 Hilt 机制。
何时使用此技能
- 用户询问“我应该如何组织 Android 测试”/“Given-When-Then 还是 Arrange-Act-Assert”/“如何命名测试方法”。
- 用户询问
@HiltAndroidTest、HiltAndroidRule、@TestInstallIn、@UninstallModules、@BindValue或规则的顺序。 - 用户反映“测试在本地通过,但在 CI 上失败”——通常是确定性/隔离性问题。
- 用户正在启动新模块,需要策略文档/约定列表。
- 用户提到用
RuleChain.outerRule将HiltAndroidRule与ActivityScenarioRule或composeTestRule组合起来。
何时不使用此技能
- 用户正在选择测试范围(small/medium/big)——使用
../../concepts/understanding-the-testing-pyramid/SKILL.md。 - 用户正在选择要覆盖的行为——使用
../../concepts/choosing-what-to-test/SKILL.md。 - 用户正在选择伪实现还是 Mock——使用
../../doubles/picking-test-doubles/SKILL.md。 - 用户正在配置源集——使用
../organizing-test-source-sets/SKILL.md。 - 用户正在调试单个不稳定的 Compose 动画测试——使用
../../../compose/synchronization/synchronizing-with-idle/SKILL.md。
前提条件
- 熟悉
../../concepts/understanding-the-testing-pyramid/SKILL.md中 small/medium/big 金字塔的表述。 - 测试目标依赖注入(使用 Hilt、手动构造函数注入或其他 DI 库)。
- 对于 Hilt 部分:
dagger.hilt.android.testing.HiltAndroidRule和dagger.hilt.android.testing.HiltAndroidTest已通过androidTestImplementation引入。 - 对于确定性:具备
kotlinx-coroutines-test、Robolectric(如果是宿主机测试),以及替换System.currentTimeMillis()/Clock.systemDefaultZone()的能力。
一手资料索引(各页面分别说明什么)
根据 tasks/research/R8-android-fundamentals.md,策略概念分布在三个不同页面。注明来源很重要,因为 PR 审阅者会引用技能文档。
| 概念 | 实际说明该概念的页面 |
|---|---|
| 定性金字塔(“大量小型测试,少量大型测试”) | /training/testing/fundamentals/strategies |
| Unit/Component/Feature/Application/RC 五层表格 | /training/testing/fundamentals/strategies |
| 各层的网络访问 | /training/testing/fundamentals/strategies |
| 确定性/不稳定测试的处理 | /training/testing/instrumented-tests/stability(不是 /strategies) |
| 隔离测试的定义 | /training/testing/fundamentals/test-doubles(仅顺带提及) |
| Given-When-Then 注释风格 | 任何页面都从未提及;只在代码示例中使用 |
| Arrange-Act-Assert | 任何页面都从未提及 |
| 测试命名 | 仅在 /training/testing/instrumented-tests 中提及反引号注意事项 |
@HiltAndroidTest、HiltAndroidRule、@TestInstallIn、@UninstallModules、@BindValue | /training/dependency-injection/hilt-testing |
不得将确定性指南归于 /strategies——相关内容在 /instrumented-tests/stability。不得声称 Google“推荐 Given-When-Then”——这些页面没有这样说;它们只是在示例中使用了这种注释风格。
确定性
根据 /training/testing/instrumented-tests/stability(以及 CORPUS §C“常见问题”),非确定性主要来自以下四个方面:
- 时间 —
System.currentTimeMillis()、Clock.systemDefaultZone()、Instant.now()。注入Clock,或使用kotlinx-coroutines-test的虚拟时间。 - 并发 / 协程 — 直接使用
Dispatchers.IO、runBlocking、真实的delay。使用runTest,注入CoroutineDispatcher,通过Dispatchers.setMain(dispatcher)替换Dispatchers.Main。参见../../../jvm-tests/coroutines/testing-coroutines-with-runtest/SKILL.md。 - 动画 / 帧计时 — Compose 的
MainTestClock、View 动画。通过testOptions.animationsDisabled = true(Gradle)或Settings.Global.WINDOW_ANIMATION_SCALE = 0(ADB)禁用动画。对于 Compose,根据docs/SPEC.md§5 中 skydoves 的指令,设置mainClock.autoAdvance = false。 - 外部状态 — 网络、真实数据库、
Random()、UUID.randomUUID()。注入种子,使用伪对象,并在每项仪器测试前运行pm clear。
具体检查清单:
- 注入
Clock(或等效时间源);测试中替换为Clock.fixed(Instant.parse(...), ZoneId.of("UTC"))。 - 通过
DispatcherProvider接口注入Dispatchers,或在@Before中使用Dispatchers.setMain(StandardTestDispatcher())替换。 - 为
Random设置种子(Random(42)),或将其封装在RandomProvider后,由测试进行替换。 - 在
build.gradle.kts中设置testOptions.animationsDisabled = true。 - CI 在每项仪器测试前运行
pm clear <pkg>(Test Orchestrator 会自动执行;否则添加@Before)。
Hermetic 测试与集成测试
/strategies 页面并未将“hermetic”正式定义为顶级术语。最接近的定义位于 /test-doubles:
“hermetic 测试不会使用任何外部依赖,例如从互联网获取数据。” —
developer.android.com/training/testing/fundamentals/test-doubles
操作规则位于 /strategies:
| 层级 | 网络访问 |
|---|---|
| 单元 | 无 |
| 组件 | 无 |
| 功能 | “支持模拟网络访问” |
| 应用 | 不适用 |
| 候选发布版 | 不适用 |
不得将“hermetic”描述成 /strategies 上的专门章节——该词仅在 /test-doubles 中顺带提及。讨论 hermetic 测试时,必须引用这两个页面:/test-doubles 用于说明术语,/strategies 用于说明操作规则。
Given-When-Then / Arrange-Act-Assert
四个主要页面(/fundamentals、/what-to-test、/test-doubles、/strategies)都没有命名这两种模式,但 Google 的代码示例中明显使用了它们。以下示例来自 /fundamentals:
// Given an instance of MyViewModel
val viewModel = MyViewModel(myFakeDataRepository)
// When data is loaded
viewModel.loadData()
// Then it should be exposing data
assertTrue(viewModel.data != null)
— developer.android.com/training/testing/fundamentals(本地 / 主机端单元测试示例)
同一页面中还有:
// When the Continue button is clicked
onView(withText("Continue")).perform(click())
// Then the Welcome screen is displayed
onView(withText("Welcome")).check(matches(isDisplayed()))
— developer.android.com/training/testing/fundamentals(Espresso 示例)
可以准确地说:“Google 的官方示例始终使用 Given-When-Then 注释块”。不得声称“Google 推荐 Given-When-Then”——这是捏造的说法。
Given-When-Then 和 Arrange-Act-Assert 的结构相同,只是术语不同:
Given (Arrange) — set up the SUT and dependencies
When (Act) — invoke the behavior under test
Then (Assert) — assert on the resulting state or interaction
每个代码库选择一种术语(skydoves 倾向于 Given-When-Then,与 Google 的示例一致)。将其用作内联注释块,而不是方法名。
测试命名
四个主要页面中,唯一逐字给出的命名指导是 /instrumented-tests 上关于反引号的说明:
“**注意:**在 Kotlin 中使用反引号命名测试,仅适用于运行 API 30 及以上版本的设备。” —
developer.android.com/training/testing/instrumented-tests
影响如下:
- 本地测试(
src/test/)——可以放心使用反引号名称(在 JVM 上运行,而不是在 Android 运行时上)。 - 仪器测试(
src/androidTest/)——使用反引号名称要求测试 APK 的minSdk >= 30。低于此版本时,请使用camelCase或snake_case_with_underscores。
skydoves 的约定(结合上述两点):使用 methodName_state_expected 这种格式,使其读起来像一个句子。
loadUsers_emptyList_emitsEmptyState
applyCoupon_negativeTotal_emitsError
save_orchestratorMarksDirtyOnce
用于测试的 Hilt — 完整机制
来源:developer.android.com/training/dependency-injection/hilt-testing 加上 CORPUS §F.6。
为什么测试需要 Hilt
“单元测试不需要 Hilt,因为测试使用构造函数注入的类时,无需使用 Hilt 来实例化该类。” —
developer.android.com/training/dependency-injection/hilt-testing
“对于集成测试,Hilt 会像在生产代码中一样注入依赖项。使用 Hilt 进行测试无需维护,因为 Hilt 会自动为每个测试生成一组新的组件。” —
developer.android.com/training/dependency-injection/hilt-testing
因此:在单元测试中使用构造函数注入(跳过 Hilt),在 UI / 集成测试中使用 Hilt。
@HiltAndroidTest + 将 HiltAndroidRule 排在第一位
@HiltAndroidTest
class HomeScreenTest {
@get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1) val composeRule = createAndroidComposeRule<MainActivity>()
@Inject lateinit var userRepository: UserRepository
@Before fun inject() = hiltRule.inject()
}
HiltAndroidRule 必须在任何会访问注入图的规则(启动 Activity、设置 Compose)之前执行。/hilt-testing 页面将其表述为“HiltAndroidRule 首先执行”。在 JUnit4 中,较小的 order 值会先执行(@Rule(order = 0) 优先于 order = 1),因此常见模式是按示例所示使用 0 然后 1——但规则是“Hilt 优先”,而不是特指“order = 0”。负数同样适用(order = -1 与 order = 0 配对),任何单调递增的排序方案都可以。根据 /hilt-testing:
“任何使用 Hilt 的 UI 测试都必须添加
@HiltAndroidTest注解。此注解负责为每个测试生成 Hilt 组件。此外,还需要将HiltAndroidRule添加到测试类中。” —developer.android.com/training/dependency-injection/hilt-testing
“要向测试中注入类型,请使用
@Inject进行字段注入。要让 Hilt 填充@Inject字段,请调用hiltRule.inject()。” —developer.android.com/training/dependency-injection/hilt-testing
@TestInstallIn — 为所有测试替换绑定
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [AnalyticsModule::class],
)
abstract class FakeAnalyticsModule {
@Singleton @Binds
abstract fun bindAnalyticsService(impl: FakeAnalyticsService): AnalyticsService
}
替换会应用于模块中的每个 @HiltAndroidTest。当同一个 Fake 适用于所有测试时(内存数据库、无操作分析),请使用此方式。
@UninstallModules — 在单个测试中替换
@UninstallModules(AnalyticsModule::class)
@HiltAndroidTest
class SettingsActivityTest {
@Module @InstallIn(SingletonComponent::class)
abstract class TestModule {
@Singleton @Binds
abstract fun bindAnalyticsService(impl: FakeAnalyticsService): AnalyticsService
}
}
根据 /hilt-testing 中的警告(必须在技能中明确说明):
“警告: 无法卸载未使用
@InstallIn注解的模块。尝试这样做会导致编译错误。”
“警告:
@UninstallModules只能卸载@InstallIn模块,不能卸载@TestInstallIn模块。” —developer.android.com/training/dependency-injection/hilt-testing
@BindValue — 在单个测试中快速替换
@UninstallModules(AnalyticsModule::class)
@HiltAndroidTest
class SettingsActivityTest {
@BindValue @JvmField
val analyticsService: AnalyticsService = FakeAnalyticsService()
}
@BindValue 是轻量级形式——无需测试模块类。当测试需要准备单个实例并对其进行断言时,请使用此方式。
@CustomTestApplication — 非 Hilt 基类
如果生产环境中的 Application 继承自非 Hilt 基类(MultiDexApplication、供应商提供的应用类),Hilt 就无法自动生成测试应用。请使用:
@CustomTestApplication(BaseApplication::class)
interface HiltTestApplication
然后,在 testInstrumentationRunnerArguments 中指定生成的 HiltTestApplication_Application,或通过自定义 AndroidJUnitRunner 指定。
使用多个规则时的规则顺序
@HiltAndroidTest
class SettingsActivityTest {
@get:Rule(order = 0) var hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1) var settingsActivityTestRule = SettingsActivityTestRule(...)
}
— developer.android.com/training/dependency-injection/hilt-testing
也可以使用 RuleChain 进行链式组合:
@get:Rule
val chain: TestRule = RuleChain
.outerRule(HiltAndroidRule(this))
.around(InstantTaskExecutorRule())
.around(MainDispatcherRule())
模式
模式:错误与正确 — Given-When-Then 注释块
// WRONG — no structure; reader cannot find the act vs assert boundary
@Test
fun loadsUser() = runTest {
val repo = FakeUserRepository().apply { seed(User(UserId("u1"), "Alice")) }
val vm = UserViewModel(repo)
vm.load(UserId("u1"))
val state = vm.state.value
assertEquals("Alice", state.name)
assertFalse(state.isLoading)
}
// WRONG because: the test crams setup, action, and assertion together. With three or
// more lines per stage, readers cannot locate failures fast. Google's own samples use
// Given-When-Then comments as visual separators.
// RIGHT
@Test
fun loadUser_seededId_emitsLoadedState() = runTest {
// Given a fake repo seeded with Alice
val repo = FakeUserRepository().apply { seed(User(UserId("u1"), "Alice")) }
val vm = UserViewModel(repo)
// When loading the seeded id
vm.load(UserId("u1"))
// Then the state is Loaded with Alice
assertEquals(UserUiState.Loaded(name = "Alice"), vm.state.value)
}
模式:错误与正确 — Hilt 规则顺序
// WRONG
@HiltAndroidTest
class HomeScreenTest {
@get:Rule val composeRule = createAndroidComposeRule<MainActivity>() // order = 0 by default
@get:Rule val hiltRule = HiltAndroidRule(this)
}
// WRONG because: with no order, JUnit picks ordering implementation-defined. The
// Activity may launch before Hilt's component is built, throwing at @Inject sites.
// Per /hilt-testing, HiltAndroidRule must run first.
// RIGHT
@HiltAndroidTest
class HomeScreenTest {
@get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1) val composeRule = createAndroidComposeRule<MainActivity>()
}
模式:错误与正确 — 非确定性时间
// WRONG
class DueDateTest {
@Test fun reportsOverdue() {
val task = Task(dueAt = Instant.now().minusSeconds(60))
assertTrue(task.isOverdue()) // depends on real wall clock; flake risk on slow CI
}
}
// RIGHT
class DueDateTest {
private val fixedNow = Instant.parse("2024-01-01T00:00:00Z")
private val clock = Clock.fixed(fixedNow, ZoneId.of("UTC"))
@Test fun reportsOverdue() {
val task = Task(dueAt = fixedNow.minusSeconds(60), clock = clock)
assertTrue(task.isOverdue())
}
}
模式:错误与正确 — 优先使用 @BindValue,而不是 every { }
// WRONG — mocking a Hilt-injected real instance
@HiltAndroidTest
class SettingsActivityTest {
@get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
@Inject lateinit var analytics: AnalyticsService
@Test fun tracks() {
// analytics is the real production instance; tests now mutate prod state
every { (analytics as RealAnalyticsService).enabled = false } // does not compile
}
}
// RIGHT — replace at the binding boundary
@UninstallModules(AnalyticsModule::class)
@HiltAndroidTest
class SettingsActivityTest {
@BindValue @JvmField
val analytics: AnalyticsService = FakeAnalyticsService()
@get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
}
强制规则
- MUST 确保
HiltAndroidRule在与任何会触及注入图的规则(Activity、Compose、Fragment、Coroutine)组合时最先执行。常见做法是在 Hilt 规则上使用@Rule(order = 0),并在下一条规则上使用@Rule(order = 1);但只要排序单调且 Hilt 最先执行即可——/hilt-testing要求“HiltAndroidRule最先执行”,并未特别要求order = 0。 - MUST 对任何带有
@Inject字段的测试类,在@Before中调用hiltRule.inject()。 - MUST NOT 对由
@TestInstallIn替换的模块添加@UninstallModules注解——根据该页面的警告框,这会导致 Hilt 编译失败。 - MUST NOT 将确定性方面的指导归于
/training/testing/fundamentals/strategies。应使用/training/testing/instrumented-tests/stability。 - MUST NOT 声称“Google 推荐 Given-When-Then”。页面只在示例中使用这种结构,并未将其作为规范。
- MUST 对于输出依赖这些因素的代码,注入
Clock(或等效的时间源)、DispatcherProvider和Random种子。 - MUST 可以自由地为本地测试使用反引号命名;对于可能在低于 API 30 的设备上运行的插桩测试,应使用
camelCase_state_expected(根据/instrumented-tests:“Kotlin 中使用反引号命名测试仅在运行 API 30 及以上版本的设备上受支持”——判断条件是运行时设备 API,而不只是模块的minSdk)。 - PREFERRED: 优先使用 Given-When-Then,而不是 Arrange-Act-Assert,与 Google 自有代码示例中的术语保持一致。
- PREFERRED: 单个测试的绑定替换优先使用
@BindValue,模块范围的替换优先使用@TestInstallIn。 - PREFERRED: 通过
testOptions.animationsDisabled = true禁用动画,而不是在每个测试中调用 ADB shell。
验证
- 每个
@HiltAndroidTest类都声明HiltAndroidRule,并设置order,确保其相对于类中的其他 JUnit 规则最先执行(通常使用@Rule(order = 0))。 - 每个带有
@Inject字段的@HiltAndroidTest类都在@Before中调用hiltRule.inject()。 - 生产代码没有引用
Fake*类;所有 fake 都位于src/androidTest/或src/test/下(或专用的core-testing模块中)。 - 没有测试方法直接读取
Instant.now()、System.currentTimeMillis()或未设种子/未注入的Random()。 - 在模块的
build.gradle.kts中设置了testOptions.animationsDisabled = true。 - CI 与本地运行结果完全一致——没有测试依赖网络、实际时间或设备的环境状态。
- 模块中的所有测试都遵循同一种结构约定(Given-When-Then 或 Arrange-Act-Assert;不能两者并用)。
参考资料
developer.android.com/training/testing/fundamentals/strategies— 定性测试金字塔、各层的网络访问表格。developer.android.com/training/testing/fundamentals/test-doubles— 对 hermetic 测试的定义(顺带提及)。developer.android.com/training/testing/instrumented-tests/stability— 确定性/不稳定测试的指导(不在 /strategies 页面中)。developer.android.com/training/testing/instrumented-tests— 反引号命名注意事项(“使用反引号……仅在运行 API 30 及更高版本的设备上受支持”)。developer.android.com/training/dependency-injection/hilt-testing—@HiltAndroidTest、HiltAndroidRule、@TestInstallIn、@UninstallModules、@BindValue、@CustomTestApplication、警告框、规则排序示例。tasks/research/R8-android-fundamentals.md— Hilt 原文引述及各页面的权威来源映射。- CORPUS §G.3 — 位于
testutils/testutils-ktx/src/jvmMain/kotlin/androidx/testutils/MainDispatcherRule.jvm.kt的MainDispatcherRuleJUnit4 封装。 - 同级技能:
../../concepts/understanding-the-testing-pyramid/SKILL.md、../../concepts/choosing-what-to-test/SKILL.md、../../doubles/picking-test-doubles/SKILL.md、../organizing-test-source-sets/SKILL.md。 - 跨类别:
../../../jvm-tests/coroutines/testing-coroutines-with-runtest/SKILL.md、../../../jvm-tests/coroutines/testing-flows-with-turbine/SKILL.md、../../../jvm-tests/runner/configuring-junit4-on-android/SKILL.md、../../../jvm-tests/robolectric/using-robolectric-correctly/SKILL.md、../../../instrumentation/runner/running-instrumented-tests-with-androidjunit4/SKILL.md、../../../instrumentation/scenarios/launching-activities-with-activityscenario/SKILL.md、../../../adb/observability/extracting-logs-with-logcat/SKILL.md。