mcpField
A
applying-testing-strategies

applying-testing-strategies

github.com/skydoves测试与质量33964 次安装22 周前更新
代码仓库

关于 applying-testing-strategies

面向 Android 团队的测试策略技能,帮助组织确定性、结构清晰的测试,并配置基于 Hilt 的依赖替换。它区分文档中的指导与团队约定,并介绍跨测试使用 Fake 的方式。

主要功能

  1. 01归纳测试不确定性的常见来源
  2. 02整理测试指导的文档来源
  3. 03说明 Given-When-Then 与命名约定
  4. 04介绍 Hilt 测试规则及顺序
  5. 05比较 Hilt 绑定替换方式

适用场景

  1. 01为新测试模块建立约定
  2. 02排查 CI 上间歇性失败的测试
  3. 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“常见问题”),非确定性主要来自以下四个方面:

  1. 时间 — System.currentTimeMillis()、Clock.systemDefaultZone()、Instant.now()。注入 Clock,或使用 kotlinx-coroutines-test 的虚拟时间。
  2. 并发 / 协程 — 直接使用 Dispatchers.IO、runBlocking、真实的 delay。使用 runTest,注入 CoroutineDispatcher,通过 Dispatchers.setMain(dispatcher) 替换 Dispatchers.Main。参见 ../../../jvm-tests/coroutines/testing-coroutines-with-runtest/SKILL.md。
  3. 动画 / 帧计时 — Compose 的 MainTestClock、View 动画。通过 testOptions.animationsDisabled = true(Gradle)或 Settings.Global.WINDOW_ANIMATION_SCALE = 0(ADB)禁用动画。对于 Compose,根据 docs/SPEC.md §5 中 skydoves 的指令,设置 mainClock.autoAdvance = false。
  4. 外部状态 — 网络、真实数据库、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 的 MainDispatcherRule JUnit4 封装。
  • 同级技能:../../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。

评论

支持 Markdown