mcpField
P
picking-test-doubles

picking-test-doubles

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

关于 picking-test-doubles

面向 Android 开发者的测试技能,介绍 fake、mock、stub、spy、dummy 和 Robolectric shadow。提供优先顺序与选择矩阵,帮助测试关注行为,而非偶然的方法调用。

主要功能

  1. 01定义六种测试替身
  2. 02建议优先使用 fake 作为可运行替代
  3. 03说明适合使用 mock 验证交互的情形
  4. 04区分 stub、spy 与 dummy
  5. 05介绍 Robolectric shadow

适用场景

  1. 01为依赖选择测试替身
  2. 02用 fake 替代过度 mock 的状态测试
  3. 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 评审中引用)

同一页面中的三条原文引文构成了优先顺序:

  1. “Fake 不需要 mock 框架,而且很轻量。优先使用它们。”

  2. “为求简单,fake 优于 stub。”

  3. “因此,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。

评论

支持 Markdown