Test Doubles — 是在单元测试中替代真实依赖的替代对象。该术语由 Gerard Meszaros 在《“xUnit Test Patterns”》(2007)一书中引入,作为 Mock、Stub、Fake、Spy 和 Dummy 的统称。据 Martin Fowler(2024),Test Doubles 可以将被测组件与其环境隔离,使测试变得确定性、快速且不依赖于外部服务。
核心要点
Test Doubles — 这是一个来自汽车工业的术语(特替演员,“double” 指替身),被引入到软件开发中。就像特替演员在危险场景中替代演员一样,Test Double 在测试场景中替代真实组件。当真实依赖不可用、缓慢、非确定性或有副作用时,这是必要的。
Test Double 的概念包括 五种具体类型,每种解决自己的问题。Meszaros 的分类是经典的,在所有现代测试指南中都有使用。类型之间的差异在于控制和验证的程度:从简单的参数填充(Dummy)到完全检查调用顺序(Mock)。
Test Doubles 的主要目标是隔离被测模块。在移动开发中,真实的依赖包括 API 服务器、数据库、文件系统、设备传感器、系统服务(LocationManager、Camera、Bluetooth)。直接使用这些组件会使测试变得缓慢、脆弱且依赖于环境。据 Google Testing Blog(2023),良好隔离的单元测试在 毫秒内运行,而集成测试则需要秒和分钟。
Gerard Meszaros 的分类包括五种 Test Doubles,它们在行为和使用目的上有所不同。理解它们之间的差异是正确单元测试的基础。
Dummy — 是传递给被测方法的对象,但从不被使用。Dummy 只是为了满足方法签名而已。在 Kotlin 中,这通常是 null、emptyList() 或带有替代物的对象。Dummy 不应包含任何逻辑——如果被调用,测试应失败。
Fake — 是接口的简化但可工作的实现。与 Mock 和 Stub 不同,Fake 包含真实的业务逻辑,但是以简化的形式。经典的例子—— InMemoryUserRepository,它将数据存储在 HashMap 中而非数据库。Fake 在需要测试依赖于状态的逻辑但不想承担真实基础设施开销时使用。
| 类型 | 用途 | 示例 |
|---|---|---|
| Dummy | 填充参数 | null,空对象 |
| Fake | 可工作的简化实现 | InMemoryRepository |
| Stub | 返回固定值 | when(api.getUser()).thenReturn(user) |
| Spy | 记录调用供检查 | verify(spy).save(user) |
| Mock | 检查交互 | verify(mock).sendEmail(email) |
Stub 针对特定调用返回预先定义的值。Stub 不检查是否被调用——它只是提供数据。在 Mockito 中,Stub 通过 when(method).thenReturn(value) 创建。当依赖需要返回特定值,但调用本身并不重要时,Stub 是理想的测试选择。
Spy — 是真实对象的一个包装,记录所有调用以便后续验证。与 Mock 不同,Spy 将调用委托给真实对象,但允许检查它们是否发生。在 Mockito 中,Spy 通过 spy(realObject) 创建。当您想使用真实对象但检查某些调用时,Spy 对于部分模拟很有用。
Mock — 是带有预定义调用期望的对象。Mock 检查特定方法是否使用特定参数按特定顺序被调用。与 Stub 不同,Mock 关注的是 行为验证,而非返回数据。Mock 是移动开发中最强大且最常用的 Test Double 类型。
Mock 和 Stub 之间的区别常常让经验丰富的开发人员也感到困惑。主要区别在于目的:Stub 检查状态(state verification),Mock 检查行为(behavior verification)。
Stub 回答的问题是:“代码返回了正确的结果吗?”。Mock 回答的问题是:“代码调用了正确的方法并使用了正确的参数吗?”。在移动开发中,当结果很重要时(例如来自仓库的数据)使用 Stub,当副作用很重要时(例如发送电子邮件、写入数据库)使用 Mock。
// Stub: 状态检查
every { repository.getUsers() } returns listOf(user)
val result = useCase.getUsers()
assertEquals(1, result.size)
// Mock: 行为检查
every { analytics.logEvent("purchase") } returns Unit
useCase.purchase(item)
verify { analytics.logEvent("purchase") }
使用 MockK(最受 Android 项目欢迎的 mocking 库)在 Kotlin 中编写所有五种 Test Doubles 的实践示例。
class InMemoryUserRepository : UserRepository {
private val store = mutableMapOf<String, User>()
override fun save(user: User) {
store[user.email] = user
}
override fun findByEmail(email: String): User? {
return store[email]
}
}
class RegisterUseCaseTest {
private val api = mockk<AuthApi>()
private val repo = spyk(InMemoryUserRepository())
private val useCase = RegisterUseCase(api, repo)
fun `register user successfully`() = runTest {
// Stub: 返回固定 API 响应
coEvery { api.register("test@test.com") } returns AuthResult.Success("token123")
val result = useCase.execute("test@test.com")
// Verify: 检查用户已保存
verify { repo.save(any()) }
assertTrue(result.isSuccess())
}
}
data class Logger(val appContext: Context, val format: FormatType)
fun `test logger with dummy context`() {
// Dummy: Context 未在 Logger 内使用
val dummyContext = mockk<Context>()
val logger = Logger(dummyContext, FormatType.JSON)
assertEquals(FormatType.JSON, logger.format)
}
选择 Test Double 类型取决于测试的具体内容:状态、行为还是集成。在 Android 和 iOS 移动开发中,形成了以下建议。
在测试 ViewModel 时,对于产生副作用的依赖(仓库、分析、导航)使用 Mock,对于返回数据的依赖(API 客户端、ContentProvider)使用 Stub。这可以检查 ViewModel 是否正确处理成功和错误场景。
在 Repository 层,更倾向于使用 Fake(内存数据库实现)和 Stub(固定 API 响应)。Fake 可以在不配置 SQLite 的情况下测试缓存逻辑和离线模式。Stub 模拟各种 HTTP 状态码:200、404、500、超时。
不正确使用 Test Doubles — 是导致脆弱测试最常见的原因之一,这些测试在每次重构时都会失败。
最常见的错误——模拟一切。如果测试中的每个依赖都被 Mock 替代,测试就失去了检查真实行为的能力。Mock 只应用于外部依赖(网络、数据库、文件系统、系统服务)。应用程序的内部组件(Value Object、data class、简单工具类)不应被替代。
第二个错误——创建 Mock 时未定义期望。如果方法在没有 every / when 的情况下被调用,Mock 将返回默认值(null、0、false)。这可能导致假阳性测试,Mock 静默地返回 null,而测试将此解释为正确行为。
第三个错误——检查每个 Mock 的每次调用。Verify 只应用于从业务逻辑角度看关键的调用。过度验证会使测试变得脆弱:生产代码中调用顺序的改变会破坏测试,而行为并未改变。
常见问题
Stub 返回数据并检查状态(返回了什么),而 Mock 检查行为(哪些方法被调用了)。Stub = “返回 X”,Mock = “检查 Y 是否使用参数 Z 被调用”。在实际测试中,一个对象常常同时担任 Stub 和 Mock 的角色。
当测试依赖于状态的逻辑时(缓存、离线模式、事务),Fake 比 Mock 更受青睐。Fake(内存实现)可以在不使用脆弱的 verify 调用的情况下测试这些场景。Mock 更适合检查数据发送:分析、推送通知、电子邮件。
对于 Kotlin 中的 Android 项目,建议使用 MockK。它支持协程、suspend 函数、sealed class 和 extension 函数,无需额外配置。对于 Java 项目,标准仍然是 Mockito — 最受欢迎的库,拥有广泛的文档。
要测试 Kotlin Flow,请将 Turbine 库与 MockK 一起使用。Turbine 简化了 Flow 发射的检查:您可以检查值的顺序、流的完成以及异常。Flow 的 Stub 返回 flowOf(value),Mock 检查 Flow 是否被收集。
可以,但是在 API 响应层面,而非 UI 组件层面。MockWebServer(OkHttp)和 WireMock 库可以在 UI 测试中替代 HTTP 响应。UI 组件本身(Compose、SwiftUI Views)不应被替代——它们的行为应通过截屏测试和 Espresso 进行测试。
总结
我们将开发一款交钥匙移动应用程序
IT Sectr自2017年以来为初创企业和企业打造iOS和Android应用程序。我们将为您提供咨询并提出最佳解决方案。