mcpField
C
choosing-what-to-test

choosing-what-to-test

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

关于 choosing-what-to-test

帮助 Android 开发者判断应测试哪些代码与状态,并识别低价值测试。它按屏幕、内存、持久化、系统及边界状态组织建议,强调覆盖业务行为和边界情况,而非框架入口。

主要功能

  1. 01按状态类型规划测试覆盖
  2. 02区分 UI、ViewModel 与数据层测试
  3. 03列出常见错误和边界情况
  4. 04识别低价值的框架入口测试
  5. 05提供测试位置决策参考

适用场景

  1. 01为模块制定测试范围
  2. 02审查代码变更中的测试缺口
  3. 03判断 Activity 或 Fragment 是否值得单测
  4. 04为函数补充边界情况测试

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/skydoves/android-testing-skills --skill choosing-what-to-test

安装后在 Claude Code 中输入/choosing-what-to-test确认已加载。

详细介绍

选择测试内容——覆盖状态,跳过框架入口

Android 测试套件往往对业务逻辑测试不足,却对框架胶水代码测试过多。Google 的 /training/testing/fundamentals/what-to-test 页面按代码涉及的状态对测试价值进行分类,并明确指出应避免哪些类别的测试。本技能整理了这些类别和边界情况检查清单,让 Agent 能给出具体建议,而不是泛泛地说“测试所有重要内容”。

何时使用此技能

  • 用户问“这个模块应该写哪些测试”或“X 需要测试吗”。
  • 用户问是否应该对 Activity、Fragment 或 Service 进行单元测试(答案通常是不需要——参见“不要测试什么”)。
  • 用户正在审查 PR,但不清楚测试缺口在哪里。
  • 用户问“这个函数还漏了哪些边界情况”。
  • 用户问测试精力应该分配在 ViewModel、Repository 和 Composable 的哪些部分。

何时不使用此技能

  • 用户已经知道要测试什么,只是在选择测试范围(小型/中型/大型)——使用 ../understanding-the-testing-pyramid/SKILL.md。
  • 用户正在选择 fake/mock/stub——使用 ../../doubles/picking-test-doubles/SKILL.md。
  • 用户正在配置源代码集(src/test/ 与 src/androidTest/)——使用 ../../strategies/organizing-test-source-sets/SKILL.md。

前置条件

  • 模块中至少有一个 ViewModel、repository 或 screen。
  • 熟悉 Android 架构分层(UI/Domain/Data),以便 Agent 能将“数据层”对应到实际路径。
  • 了解 ../understanding-the-testing-pyramid/SKILL.md 中的三层金字塔框架。

需要覆盖的五类状态

以下五类状态是对用户所需内容的归纳。它们对应 developer.android.com/training/testing/fundamentals/what-to-test 中的原文部分(该页面本身使用分层风格的标题——“Essential unit tests”“UI tests”“Testing Edge Cases”——但底层分类依据是发生变化的状态类型)。

1. 屏幕上的状态——用户此刻看到的内容

渲染出来的内容:按钮标签、列表数量、启用/禁用状态、复选框切换状态。通过 UI 测试进行验证:

"Screen UI tests check critical user interactions in a single screen. They perform actions such as clicking on buttons, typing in forms, and checking visible states. One test class per screen is a good starting point." — developer.android.com/training/testing/fundamentals/what-to-test

"User flow tests or Navigation tests, covering most common paths. These tests simulate a user moving through a navigation flow." — developer.android.com/training/testing/fundamentals/what-to-test

对于 Compose:通过 onNodeWithTag(...).assertIsDisplayed() / assertTextEquals(...) / assertIsEnabled() 进行断言。对于 Views:使用 onView(...).check(matches(isDisplayed()))。使用跨类别技能 ../../../instrumentation/espresso/writing-espresso-tests/SKILL.md。

2. 内存中的状态——ViewModel 和 presenter 状态

ViewModel.uiState、内存缓存、当前选中项。通过小型单元测试进行验证:

"Unit tests for ViewModels, or presenters." — developer.android.com/training/testing/fundamentals/what-to-test

ViewModel 测试会用 fake repository 启动 ViewModel,触发一个事件,然后断言暴露的 StateFlow / LiveData。参见 ../../../jvm-tests/coroutines/testing-coroutines-with-runtest/SKILL.md 和 ../../../jvm-tests/coroutines/testing-flows-with-turbine/SKILL.md。

3. 持久化状态 — DB、DataStore、SharedPreferences、文件

进程终止后仍会保留的内容:

“数据层的单元测试,尤其是仓库测试。数据层的大部分内容应该与平台无关。这样就能在测试中使用测试替身,替换数据库模块和远程数据源。” — developer.android.com/training/testing/fundamentals/what-to-test

两种类型:

  • 仓库单元测试 — 对存储源使用伪实现;验证仓库的缓存、映射和错误处理。
  • 存储集成测试 — 使用 Room / DataStore / SharedPreferences 的真实实现进行测试。根据 /fundamentals 的说法,这属于小型插桩测试(“小型插桩测试:你可以验证代码是否能与某项框架功能(例如 SQLite 数据库)良好协作”)。
4. 其他状态 — 系统栏、系统服务、闹钟、广播

窗口插入状态、深色模式标志、闹钟调度、后台作业状态。/what-to-test 页面没有为此单独设置标题,但 /fundamentals 页面在插桩测试部分涵盖了这些内容。如果存在 shadow,可通过 Robolectric(本地中型测试)进行测试;如果需要真实系统,则使用插桩测试。

5. 错误和边界情况

“单元测试应同时关注正常情况和边界情况。边界情况是人类测试人员和大型测试不太可能发现的罕见场景。示例包括:

  • 使用负数、零和边界条件进行数学运算。
  • 所有可能的网络连接错误。
  • 损坏的数据,例如格式错误的 JSON。
  • 模拟保存文件时存储空间已满的情况。
  • 在进程中途重新创建对象(例如设备旋转时重新创建 Activity)。” — developer.android.com/training/testing/fundamentals/what-to-test

这是任何测试预算中价值最高的一项——人类会漏掉这些问题,大型测试也会漏掉,而一个由伪实现驱动的小型单元测试能在 5 ms 内捕获它们。

不要测试的内容(原文)

该页面在这一点上说得格外直接:

“有些单元测试价值较低,应当避免:

  • 验证框架或库是否正常运行,而非验证你自己的代码。
  • Activity、Fragment 或 Service 等框架入口不应包含业务逻辑,因此不应优先对它们进行单元测试。Activity 的单元测试价值很低,因为它们主要覆盖框架代码,而且需要更复杂的设置。插桩测试(例如 UI 测试)可以覆盖这些类。” — developer.android.com/training/testing/fundamentals/what-to-test

实际注意事项:

  • 绝对不要编写 Robolectric 单元测试来检查 Activity 的 onCreate,以断言视图已完成膨胀。这是在测试框架。
  • 绝对不要编写单元测试来检查 RoomDatabase.Builder 的迁移注册。这是在测试 Room。
  • 绝对不要编写单元测试来模拟 Retrofit,以断言 @GET 路径能正确解析。这是在测试 Retrofit。
  • 绝对不要测试每次重构都会变化的私有实现细节。测试公开的可观察行为。
  • **首选做法:**如果一个类难以进行单元测试,就将业务逻辑从中提取出来进行重构(Activity → ViewModel → UseCase → Repository)——该页面以此收尾:“采用可测试的应用架构后,代码会遵循一种结构,让你能轻松地隔离测试不同部分。”(引用自 /fundamentals)。

该页面还简要提到了其他类别:

“还有更专业的测试,例如截图测试、性能测试和猴子测试。” — developer.android.com/training/testing/fundamentals/what-to-test

这些不属于“测试什么”这一决策的范围,但如果用户询问视觉回归或渲染正确性,就应提及——参见跨类别技能 ../../../compose/audit/auditing-compose-test-suite/SKILL.md。

模式

模式:错误做法 vs 正确做法——测试 Activity,而不是其 ViewModel
// WRONG
@RunWith(AndroidJUnit4::class)
class CheckoutActivityTest {
    @get:Rule val rule = ActivityScenarioRule(CheckoutActivity::class.java)

    @Test fun whenTotalIsNegative_showsError() {
        rule.scenario.onActivity { activity ->
            activity.viewModel.applyCoupon("FREE_BEER_NEGATIVE_PRICE")
            // assert on the activity's TextView contents
            assertEquals("Error", activity.findViewById<TextView>(R.id.error).text)
        }
    }
}
// WRONG because: the test rebuilds the framework lifecycle, the Activity, the inflater,
// and the View graph just to assert business logic that lives in the ViewModel. Per
// /what-to-test: "Unit tests for activities have little value, because they would cover
// mostly framework code".
// RIGHT — small, JVM, sub-50 ms
class CheckoutViewModelTest {
    @Test fun applyCoupon_negativePrice_emitsError() = runTest {
        val vm = CheckoutViewModel(FakePricingRepository(allowNegative = true))
        vm.applyCoupon("FREE_BEER_NEGATIVE_PRICE")
        assertEquals(CheckoutUiState.Error("Negative total"), vm.uiState.value)
    }
}

仍然要为 Activity 编写一个精简的大型测试(Espresso / Compose-test),断言错误字符串已渲染。逻辑则放在 ViewModel 测试中。两个层次,各司其职。

模式:错误做法 vs 正确做法——过度模拟实用工具类
// WRONG
class FormatDurationTest {
    private val clock = mockk<Clock>()
    private val locale = mockk<Locale>()
    @Test fun formatsMinutesAndSeconds() {
        every { clock.now() } returns Instant.parse("2024-01-01T00:01:30Z")
        every { locale.language } returns "en"
        assertEquals("1m 30s", formatDuration(90.seconds, clock, locale))
    }
}
// WRONG because: this is a pure-function utility per /what-to-test ("Unit tests for
// utility classes such as string manipulation and math"). Mocks add ceremony with
// no value. Pass real values.
// RIGHT
class FormatDurationTest {
    @Test fun formatsMinutesAndSeconds() {
        assertEquals("1m 30s", formatDuration(90.seconds))
    }

    @Test fun handlesZero() {
        assertEquals("0s", formatDuration(0.seconds))
    }

    @Test fun handlesHoursBoundary() {
        assertEquals("1h 0m 0s", formatDuration(3600.seconds))
    }
}
模式:错误做法 vs 正确做法——跳过边界情况检查清单
// WRONG — only the happy path
class ParseAmountTest {
    @Test fun parsesValidAmount() = assertEquals(12.34, parseAmount("12.34"))
}
// RIGHT — edge cases per /what-to-test
class ParseAmountTest {
    @Test fun parsesValidAmount() = assertEquals(12.34, parseAmount("12.34"))
    @Test fun parsesZero() = assertEquals(0.0, parseAmount("0"))
    @Test fun parsesNegative() = assertEquals(-5.0, parseAmount("-5"))
    @Test fun parsesScientific() = assertEquals(1e6, parseAmount("1e6"))
    @Test fun rejectsEmpty() = assertNull(parseAmount(""))
    @Test fun rejectsMalformed() = assertNull(parseAmount("12.34.56"))
    @Test fun rejectsLeadingWhitespace() = assertNull(parseAmount(" 12"))
    @Test fun rejectsLocaleComma() = assertNull(parseAmount("12,34"))  // German format
    @Test fun handlesMaxDouble() = assertEquals(Double.MAX_VALUE, parseAmount("1.7976931348623157E308"))
}

边界情况挖掘检查清单

在宣布单元测试通过之前,先运行这份检查清单。条目对应 /training/testing/fundamentals/what-to-test 上的原文列表,以及 skydoves 针对 Compose / 协程代码的补充内容。

  • 数值边界 — 零、负数、Int.MAX_VALUE、Long.MIN_VALUE、Double.NaN、Double.POSITIVE_INFINITY。
  • 空集合 / 单个元素 / 多个元素 — emptyList()、listOf(x)、包含数千个元素的列表。
  • 空值 — 每个可空输入。即使类型系统会拒绝,反序列化仍可能产生空值(Moshi / kotlinx-serialization 配合 coerceInputValues)。
  • 格式错误的字符串 — 空字符串、仅含空白字符的字符串、长度为 1、长度为 MAX、非 ASCII 字符、RTL 文本、表情符号、ZWJ 序列、代理对。
  • 区域设置变化 — 德语数字格式 12,34、阿拉伯数字 ٠١٢、土耳其语带点的 i。
  • 网络错误 — IOException、HTTP 4xx、HTTP 5xx、格式错误的 JSON、截断的流、超时。
  • 存储错误 — 磁盘已满、权限被拒绝、SQLite 损坏、加密的偏好设置。
  • 进程终止 / 状态恢复 — 根据该页面所述,“进程运行期间对象被重新创建(例如设备旋转时的 Activity)”。覆盖 rememberSaveable、SavedStateHandle、Bundle 恢复。
  • 并发 — 两个协程执行相同操作、执行中途取消、生产者缓慢而消费者快速(使用 kotlinx-coroutines-test)。
  • 时间 — 夏令时切换、闰秒、跨越午夜、时区变更、单调时钟与墙上时钟。
  • 配置变更 — 深色模式切换、区域设置变更、字体缩放至 1.5x 和 2.0x、RTL 布局。

前半部分直接来自 Google 的原文列表;后半部分是面向现代 Android 的实用扩展。

决策矩阵——这段代码是否应该有测试?

What does the code touch?                              Test? Where?
-----------------------------------------------------------------------------
Pure function / utility (string, math, mapping)        YES   src/test/ (small)
ViewModel / presenter state                            YES   src/test/ (small)
Repository / use case (fake the storage)               YES   src/test/ (small)
Real Room / DataStore                                  YES   src/androidTest/ (small instrumented)
Composable rendering of state                          YES   src/androidTest/ or src/test/ (Robolectric)
Navigation flow (multi-screen)                         YES   src/androidTest/ (big)
Activity onCreate / inflation logic                    NO    framework code
Fragment onViewCreated wiring                          NO    framework code
Service onStartCommand wiring                          NO    framework code
Retrofit interface (annotation routing)                NO    library code
Hilt module declarations                               NO    library code
Compose Modifier internals                             NO    library code
Generated code                                         NO    generated
Private function with no public observable effect      NO    test the public caller

强制规则

  • 必须测试公开的可观察行为(ViewModel 暴露的状态、Repository 的返回值、渲染出的语义)——绝不测试私有内部实现。
  • 不得对框架入口点(Activity、Fragment、Service)的业务逻辑进行单元测试;应改为将逻辑重构到其他地方。根据 /what-to-test:“框架入口点……不应包含业务逻辑,因此单元测试不应成为优先事项”。
  • 必须在合并前覆盖原文列表中的边界情况:数值边界、网络错误、格式错误的数据、存储空间已满、进程重新创建。
  • 不得验证框架或第三方库的行为。“验证框架或库是否正常运行,而非验证你的代码”的测试明确列在应避免的清单中。
  • 优先:每个屏幕保留一个 Compose / Espresso 大型测试,用于覆盖“屏幕上的状态”;其他情况尽量依靠小型 ViewModel 测试。
  • 优先:如果某个类难以测试,应将其视为架构信号——生产代码承担了过多职责,或在错误的层级持有状态。

验证

  • 模块中每个 ViewModel 和 Repository 的每个公共函数都至少有一个小型测试。
  • 每个屏幕都至少有一个大型或中型测试,通过 assertIsDisplayed / check(matches(isDisplayed())) 断言“屏幕上的状态”。
  • 没有测试类仅针对 Activity / Fragment / Service 来断言由框架处理的行为。
  • 对于每个输入范围并非简单的新增公共函数,都审查上述边界情况清单。
  • 没有测试会模拟 Retrofit、Room、Hilt 或 Compose 的内部实现来验证其行为。
  • PR 模板(或评审清单)要求在合并前回答“这段代码会触及什么状态”。

参考资料

  • developer.android.com/training/testing/fundamentals/what-to-test — 本技能中所有引文的权威来源。
  • developer.android.com/training/testing/fundamentals — 关于可测试架构的说明,以及“并非所有单元测试都是本地测试”这一引文。
  • developer.android.com/training/testing/local-tests — “注意:应避免使用复杂的模拟对象。相反,可以使用不同类型的测试替身,例如伪对象”。
  • developer.android.com/training/testing/instrumented-tests — 说明屏幕状态测试何时必须使用插桩测试,而非 Robolectric。
  • tasks/research/R8-android-fundamentals.md — “Essential unit tests” / “Testing Edge Cases” / “Unit Tests to Avoid”的逐字摘录。
  • 同级技能:../understanding-the-testing-pyramid/SKILL.md、../../doubles/picking-test-doubles/SKILL.md、../../strategies/applying-testing-strategies/SKILL.md、../../strategies/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/mocking/mocking-with-mockito/SKILL.md、../../../jvm-tests/mocking/mocking-with-mockk/SKILL.md、../../../instrumentation/espresso/writing-espresso-tests/SKILL.md、../../../instrumentation/scenarios/launching-activities-with-activityscenario/SKILL.md、../../../instrumentation/scenarios/launching-fragments-with-fragmentscenario/SKILL.md、../../../compose/audit/auditing-compose-test-suite/SKILL.md。

评论

支持 Markdown