关于 picking-test-doubles
面向 Android 开发者的测试技能,介绍 fake、mock、stub、spy、dummy 和 Robolectric shadow。提供优先顺序与选择矩阵,帮助测试关注行为,而非偶然的方法调用。
主要功能
- 01定义六种测试替身
- 02建议优先使用 fake 作为可运行替代
- 03说明适合使用 mock 验证交互的情形
- 04区分 stub、spy 与 dummy
- 05介绍 Robolectric shadow
适用场景
- 01为依赖选择测试替身
- 02用 fake 替代过度 mock 的状态测试
- 03用 mock 验证必要交互
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill picking-test-doubles安装后在 Claude Code 中输入/picking-test-doubles确认已加载。
详细介绍
选择测试替身——优先使用 Fake;仅在验证交互时使用 Mock
Google 对测试替身有明确的优先顺序,并在 /training/testing/fundamentals/test-doubles 中三次说明。大多数 Android 代码库过度使用 Mock、很少使用 Fake,导致测试固定的是实现细节(调用了哪些方法),而不是行为(系统最终处于什么状态)。本技能收录了六种测试替身、原文列出的优先顺序,以及用于选择合适测试替身的决策矩阵。Mocking 库本身的框架机制由相邻技能介绍(../../../jvm-tests/mocking/mocking-with-mockito/SKILL.md、../../../jvm-tests/mocking/mocking-with-mockk/SKILL.md)。
何时使用本技能
- 用户询问“应该使用 Fake 还是 Mock”“Mockito 还是 MockK”“Stub 和 Spy 有什么区别”。
- 用户正在测试中替换某个依赖,但不确定该使用哪种测试替身。
- 正在审查的 PR 到处使用
verify(repo).getUser(any()),而 Agent 怀疑存在过度 Mock 的情况。 - 用户提到
Robolectric“shadows”,并询问它们在测试替身分类中属于哪一类。 - 用户写出“每次调用
repo.getUser()都返回User('Alice')”,但没有待测行为,Agent 应建议使用 Fake。
何时不要使用本技能
- 用户已经选择了 Fake 或 Mock,只想了解框架机制(
whenever/every/verify/argumentCaptor)——请使用../../../jvm-tests/mocking/mocking-with-mockito/SKILL.md或../../../jvm-tests/mocking/mocking-with-mockk/SKILL.md。 - 用户正在配置 Hilt 以替换绑定——请将本技能与
../../strategies/applying-testing-strategies/SKILL.md(Hilt 部分)结合使用。 - 用户要选择测试什么,而不是选择如何测试——请使用
../../concepts/choosing-what-to-test/SKILL.md。 - 用户正在调试某个 API 会抛出异常的 Robolectric shadow——请使用
../../../jvm-tests/robolectric/using-robolectric-correctly/SKILL.md。
前置条件
- 测试目标的依赖已注入(通过构造函数注入或基于接口的 DI)。如果单例是硬编码的,必须先重构,才能替换为测试替身。
- 如果需要 Mock、Stub 或 Spy,请在
testImplementation中添加mockito-core+mockito-kotlin或io.mockk:mockk。请参阅../../strategies/organizing-test-source-sets/SKILL.md。 - 对于 shadows:在
testImplementation中添加org.robolectric:robolectric,并设置testOptions.unitTests.includeAndroidResources = true。
六种测试替身(定义原文)
以下六种定义——Fake、Mock、Stub、Spy、Dummy 和 Shadow——均照录自 developer.android.com/training/testing/fundamentals/test-doubles。Shadow 是 Robolectric 专用的测试替身,Google 的页面将其与经典五种一同列出。
Fake — 首选
“Fake:一种测试替身,具有类的‘可用’实现,但其实现方式适合测试、不适合生产环境。例如:内存数据库。Fake 不需要 Mocking 框架,而且轻量。应优先使用 Fake。” —
developer.android.com/training/testing/fundamentals/test-doubles
Mock — 用于验证交互
“Mock:一种测试替身,其行为由你编程设定,并对其交互有预期。如果交互与定义的要求不符,Mock 会导致测试失败。通常使用 Mocking 框架创建 Mock,以实现这些功能。例如:验证数据库中的某个方法是否恰好调用了一次。” —
developer.android.com/training/testing/fundamentals/test-doubles
Stub — 预设响应,不设预期
“Stub:一种测试替身,其行为由你编程设定,但对其交互没有预期。通常使用 Mocking 框架创建。为简化起见,优先使用 Fake 而不是 Stub。” —
developer.android.com/training/testing/fundamentals/test-doubles
Spy — 包装真实对象
“Spy:对真实对象的包装,同时还会记录一些额外信息,类似于 mock。通常会避免使用它们,以免增加复杂性。因此,fake 或 mock 优于 spy。” —
developer.android.com/training/testing/fundamentals/test-doubles
Dummy — 传入但从不使用
“Dummy:一种在代码中传递但不使用的测试替身,例如只需要将它作为参数提供时。示例:作为点击回调传入的空函数。” —
developer.android.com/training/testing/fundamentals/test-doubles
Shadow — Robolectric 通过字节码实现的 fake
“Shadow:Robolectric 中使用的 fake。” —
developer.android.com/training/testing/fundamentals/test-doubles
Shadow 会在字节码层面将 Android 框架类替换为 JVM 实现,例如 ShadowSystemClock、ShadowLooper、ShadowApplication。它本质上是 fake(可运行的实现,不适用于生产环境),但其作用范围独特,仅限于开发者无法通过其他方式替换的 Android 框架类。参见 ../../../jvm-tests/robolectric/using-robolectric-correctly/SKILL.md。
优先顺序(在 PR 评审中引用)
同一页面中的三条原文引文构成了优先顺序:
-
“Fake 不需要 mock 框架,而且很轻量。优先使用它们。”
-
“为求简单,fake 优于 stub。”
-
“因此,fake 或 mock 优于 spy。”
综合如下:
Fake > Mock ≈ Stub > Spy
↑ (mock when verifying interactions; stub when only canned data)
Dummy 不在此顺序中——它们是占位符,不包含真正的测试逻辑。Shadow 是按构造方式实现的 fake,专用于框架类。
Android 中的具体示例
Fake — 使用内存 map 的 FakeUserRepository
/test-doubles 页面展示了一个 fake 示例。切勿原样复制页面中的示例——其中有拼写错误 val const UserAlice = User("Alice")(正确的 Kotlin 写法是 const val,而不是 val const)。已在 tasks/research/R8-android-fundamentals.md 中核实。请使用以下修正并扩展后的版本:
// RIGHT — fake repository with an in-memory map
class FakeUserRepository : UserRepository {
private val users = mutableMapOf<UserId, User>()
override suspend fun get(id: UserId): User? = users[id]
override suspend fun save(user: User) {
users[user.id] = user
}
override fun observeAll(): Flow<List<User>> = flow {
while (currentCoroutineContext().isActive) {
emit(users.values.toList())
delay(100)
}
}
// Test-only seeding helpers; production interface does NOT expose these.
fun seed(vararg users: User) {
users.forEach { this.users[it.id] = it }
}
fun clear() = users.clear()
}
@Test
fun loadsSeededUser() = runTest {
val repo = FakeUserRepository().apply { seed(User(UserId("u1"), "Alice")) }
val vm = UserViewModel(repo)
vm.load(UserId("u1"))
assertEquals("Alice", vm.state.value.name)
}
这个 fake 会编译到测试源集;生产代码不会接触到它。这里没有 every { } 代码块、没有 mock 初始化,也没有验证流程。
Mock — 使用 mockk<UserRepository>() 验证交互
当交互本身就是被测系统(SUT),而不是最终状态时,使用 mock。例如:保存后必须恰好调用一次 markDirty(id) 的同步引擎。
@Test
fun save_marksDirty_exactlyOnce() = runTest {
val repo = mockk<UserRepository>(relaxUnitFun = true)
val syncQueue = mockk<SyncQueue>(relaxUnitFun = true)
val sut = UserSaveOrchestrator(repo, syncQueue)
sut.save(User(UserId("u1"), "Alice"))
coVerify(exactly = 1) { syncQueue.markDirty(UserId("u1")) }
}
Mockito-kotlin 的等效写法(根据 CORPUS 第 G.3 节,这是 Android 的典型模式——在 androidx 检出目录中,“400 多个 Kotlin 文件导入 org.mockito,零个文件导入 io.mockk”):
@Test
fun save_marksDirty_exactlyOnce() = runTest {
val repo: UserRepository = mock()
val syncQueue: SyncQueue = mock()
val sut = UserSaveOrchestrator(repo, syncQueue)
sut.save(User(UserId("u1"), "Alice"))
verify(syncQueue).markDirty(UserId("u1"))
verifyNoMoreInteractions(syncQueue)
}
Stub — 预设数据,不做验证
当测试唯一需要的是返回值,并且你无法方便地编写 fake 时使用。
@Test
fun loadsCachedAvatarUrl() = runTest {
val avatarApi: AvatarApi = mock()
whenever(avatarApi.getUrl(UserId("u1"))).thenReturn("https://cdn/u1.png")
val sut = AvatarPresenter(avatarApi)
sut.load(UserId("u1"))
assertEquals("https://cdn/u1.png", sut.url.value)
// Note: no verify {} — this is a stub, not a mock.
}
如果 AvatarApi 有多个方法,或者测试需要为许多调用预置数据,请按照优先顺序将 stub 换成 FakeAvatarApi。
Spy — 包装真实对象(避免使用)
Spy 被列为“通常会避免使用,以免增加复杂性”。一种合理的使用情形是:需要对一个难以 fake 的类进行部分替换,而开发者又无法证明完整 fake 的必要性。即便如此,只要可能,也应先重构该类。
// WORKS but DISCOURAGED
val realCache = LruCache<String, Bitmap>(8)
val spied = spyk(realCache)
every { spied.evictAll() } answers { /* no-op for the test */ }
Spy 允许测试覆盖一个方法,同时保留其余部分的真实实现。代价是隐藏的耦合:除了 evictAll 之外,测试现在依赖 LruCache 的所有真实行为,而任一方的重构都会以不明显的方式破坏测试。
Dummy — 占位参数
val noOpClickHandler: () -> Unit = {} // dummy — type-checks, never invoked
val sut = ClickableComponent(onClick = noOpClickHandler)
sut.bind(...) // test does not interact with onClick at all
Shadow — ShadowSystemClock:通过字节码实现的 fake
当 SUT 直接调用 SystemClock.uptimeMillis() 时,将其抽象出来就需要重构;Robolectric 提供了一个 shadow,可由测试显式驱动:
@RunWith(AndroidJUnit4::class)
class TimerTest {
@Test
fun reportsElapsedTime() {
ShadowSystemClock.setCurrentTimeMillis(0)
val timer = Timer()
ShadowSystemClock.advanceBy(Duration.ofMillis(1500))
assertEquals(1500L, timer.elapsed())
}
}
ShadowSystemClock 本身就是 fake(内存中的实现,不适用于生产环境)——它只是以 Robolectric shadow 的形式提供,因为生产代码调用了一个 final Android 框架方法,任何 DI 接缝都无法拦截它。
决策矩阵
The intent of the test Pick
----------------------------------------------------------------
Need a working substitute for a class with state Fake
(in-memory DB, in-memory user map, in-memory clock)
Need to verify a specific interaction happened Mock
("markDirty was called once with id u1")
Need a canned return value, no behavior, no verification Stub
(and a Fake would be heavyweight for this one method)
Need partial real / partial fake on the same class Spy (last resort; refactor preferred)
Need a placeholder so a parameter type-checks Dummy
Need to substitute an Android framework class (final API) Shadow (Robolectric)
如果两个替身都适用,选择偏好列表中排在前面的那个。
模式
模式:错误与正确——状态相关场景中使用 fake 而非 mock
// WRONG — using a mock to provide state
@Test
fun loadsAndDisplaysFirstUser() = runTest {
val repo = mockk<UserRepository>()
coEvery { repo.get(UserId("u1")) } returns User(UserId("u1"), "Alice")
coEvery { repo.get(UserId("u2")) } returns User(UserId("u2"), "Bob")
coEvery { repo.observeAll() } returns flowOf(
listOf(User(UserId("u1"), "Alice"), User(UserId("u2"), "Bob"))
)
val vm = UserViewModel(repo)
vm.load(UserId("u1"))
assertEquals("Alice", vm.state.value.name)
}
// WRONG because: the test uses a mocking framework purely to seed data. Per /test-doubles:
// "Fakes are preferred over stubs for simplicity" and stubs are at least the lighter form
// of mock-as-data-source. A FakeUserRepository compiles, has no every {} ceremony, and
// the seed helper is reusable across tests.
// RIGHT — fake provides state; ViewModel test asserts state
@Test
fun loadsAndDisplaysFirstUser() = runTest {
val repo = FakeUserRepository().apply {
seed(User(UserId("u1"), "Alice"), User(UserId("u2"), "Bob"))
}
val vm = UserViewModel(repo)
vm.load(UserId("u1"))
assertEquals("Alice", vm.state.value.name)
}
模式:错误与正确——在 fake 上进行验证
// WRONG — verifying a method call on a fake
@Test
fun save_callsRepository() = runTest {
val repo = FakeUserRepository()
val sut = UserSaveOrchestrator(repo)
sut.save(User(UserId("u1"), "Alice"))
// Now the test wants to verify save() was called. Fakes don't track calls.
// Tempting fix: switch to spyk(repo). DON'T.
val tracking = spyk(repo)
coVerify { tracking.save(any()) }
}
// WRONG because: this confuses "did the right state result?" (fake territory) with
// "was the right method called?" (mock territory). Pick one. If the test is verifying
// state, assert on repo.get(UserId("u1")) instead. If the test is verifying interaction,
// drop the fake and use a mock.
// RIGHT — fake answers state assertions
@Test
fun save_persistsUser() = runTest {
val repo = FakeUserRepository()
val sut = UserSaveOrchestrator(repo)
sut.save(User(UserId("u1"), "Alice"))
assertEquals(User(UserId("u1"), "Alice"), repo.get(UserId("u1")))
}
// RIGHT — mock answers interaction assertions (separate test)
@Test
fun save_emitsSyncSignal() = runTest {
val repo: UserRepository = mock()
val syncQueue: SyncQueue = mock()
val sut = UserSaveOrchestrator(repo, syncQueue)
sut.save(User(UserId("u1"), "Alice"))
verify(syncQueue).markDirty(UserId("u1"))
}
模式:错误与正确——过度使用 spy
// WRONG
val cache = spyk(LruCache<String, Bitmap>(8))
every { cache.put(any(), any()) } answers {
// intercept and log
println("put: ${invocation.args[0]}")
callOriginal()
}
错误原因:根据 /test-doubles,spy“通常应避免使用,因为它会增加复杂性。因此,fake 或 mock 优先于 spy”。此处的拦截器应放在 fake LRU 实现中,由它将调用记录到一个列表,供测试断言。
// RIGHT
class RecordingCache : Cache<String, Bitmap> {
val puts = mutableListOf<Pair<String, Bitmap>>()
override fun put(key: String, value: Bitmap) { puts += key to value }
override fun get(key: String): Bitmap? = null
}
模式:错误与正确——Hilt + fake 绑定
// WRONG — production module with no test override
@Module @InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds abstract fun bind(impl: RealUserRepository): UserRepository
}
@HiltAndroidTest
class HomeScreenTest {
@Inject lateinit var repo: UserRepository // real repo, real DB, slow + non-hermetic
// ...
}
// RIGHT — replace at the module boundary
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [RepositoryModule::class],
)
abstract class FakeRepositoryModule {
@Binds abstract fun bind(fake: FakeUserRepository): UserRepository
}
fake 仅编译到测试源集;生产依赖图不受影响。完整的 Hilt 规则排序模式请参阅 ../../strategies/applying-testing-strategies/SKILL.md。
强制规则
- 必须优先使用 fake,而非 mock/stub,来替代持有状态的依赖。根据
/test-doubles:“Fake……是首选。” - 必须优先使用 mock,而非 stub/spy,来断言交互行为。Stub 是不带验证的 mock;如果没有验证交互行为,就改用 fake。
- 不得使用 spy,除非其他所有替身都不切实际,且已在测试中记录部分真实实现耦合所带来的代价。
- 不得验证框架或库的调用。(参见
../../concepts/choosing-what-to-test/SKILL.md中的“What NOT to test”。) - 不得逐字复制
/test-doubles中的FakeUserRepository代码片段——其中包含语法错误val const UserAlice = User(...)。请按此顺序使用private val或const val。 - **推荐:**在
testFixtures源集或共享的core-testing模块中,为每个 repository 接口保留一个规范的FakeXRepository;并在 ViewModel 测试中重复使用。 - **推荐:**如果一个类难以伪造,在使用 spy 之前先重构它(提取接口)。
- **推荐:**Android 新代码使用 Mockito-kotlin(
mock<T>()、whenever、argumentCaptor)——根据 CORPUS §G.3,它本身就是androidx/中的事实标准。只有在需要 Kotlin 特有功能(协程、顶层函数、sealed mocking)时才使用 MockK。
验证
- 测试套件中的每个
mockk<X>()/mock<X>()都有verify { }/verify(x)调用(是真正的 mock),或已替换为FakeX(原本实际上是 fake-as-mock 反模式)。 - 没有测试在使用
spyk(x)/spy(x)时缺少内联注释,说明为什么 fake 或 mock 不切实际。 - 每个 repository 接口最多只有一个供各测试共享的规范
FakeXRepository。 - 没有生产代码引用
Fake*类(测试源集绝不会泄漏到发布版本中)。 - 基于 Hilt 的 UI 测试使用
@TestInstallIn或@UninstallModules+@BindValue,将真实绑定替换为 fake——绝不对真实注入实例使用every { }。 - 仓库中不存在从
/test-doubles页面复制的错误 Kotlin 代码val const UserAlice。
参考资料
developer.android.com/training/testing/fundamentals/test-doubles— 原文定义、三个偏好引述,以及指向 Hilt 的交叉链接。developer.android.com/training/testing/local-tests— “注意:应避免使用复杂的模拟对象。你可以改用不同类型的测试替身,例如伪实现或 Robolectric shadows。”developer.android.com/training/dependency-injection/hilt-testing—@TestInstallIn、@UninstallModules、@BindValue,以及应最先执行的HiltAndroidRule。tasks/research/R8-android-fundamentals.md— 测试替身引述原文,以及 Google 页面上注明的拼写错误(val const UserAlice)。developer.android.com/develop/ui/compose/testing— Compose 侧的测试替身(跨领域)。- Software Engineering at Google,第 13 章(“Test Doubles”)— 更深入地说明为何优先选择伪实现。https://abseil.io/resources/swe-book
- 同级技能:
../../concepts/understanding-the-testing-pyramid/SKILL.md、../../concepts/choosing-what-to-test/SKILL.md、../../strategies/applying-testing-strategies/SKILL.md、../../strategies/organizing-test-source-sets/SKILL.md。 - 跨类别(框架机制):
../../../jvm-tests/mocking/mocking-with-mockito/SKILL.md、../../../jvm-tests/mocking/mocking-with-mockk/SKILL.md、../../../jvm-tests/robolectric/using-robolectric-correctly/SKILL.md、../../../jvm-tests/coroutines/testing-coroutines-with-runtest/SKILL.md、../../../jvm-tests/coroutines/testing-flows-with-turbine/SKILL.md。