Fake(假对象)—— 一个可工作的简化依赖实现,行为类似于真实组件,但使用内存存储或其他轻量级机制代替生产基础设施。与 stub 不同,fake 包含真实的业务逻辑 —— 排序、过滤、聚合 —— 只是没有外部效应。内存数据库代替 Room,或 HashMap 代替 SharedPreferences —— 经典示例。更多信息请参见 Martin Fowler 的 test doubles 分类。
要点
Fake —— 一个完整但轻量的接口实现,适用于测试。该术语由 Gerard Meszaros(2007)在《xUnit Test Patterns》一书中引入。与返回严格预定义答案的 stub 不同,fake 包含可执行代码:它可以排序列表、按条件过滤、计数记录。与生产实现的唯一区别 —— fake 使用内存数据,不执行真实的 I/O 操作。
主要优势 —— 速度。使用 fake 的测试在毫秒内完成,因为没有磁盘、网络或数据库访问。内存 HashMap 比 Room 或 CoreData 快 100-1000 倍。同时,fake 检查真实的业务逻辑:排序、过滤、聚合 —— 所有 stub 无法检查的内容,因为 stub 只返回被告知的内容。Fake 提供了代码正确处理数据的信心,而不仅仅是接收预定义的答案。
Fake 优于 Stub —— 如果被测试组件在数据上执行多个操作(提取、过滤、排序、保存),stub 需要单独配置每个调用。Fake 内部包含逻辑 —— 测试只需调用方法并检查结果。在 IT Sectr,我们在单元测试中对所有仓库使用 fake:带 HashMap 的 fake 仓库无需配置 Mockito 或 MockK 即可覆盖 90% 的场景。
选择标准 —— 确定测试检查的内容:状态还是交互。如果测试检查状态(工作结果)并使用逻辑 —— 需要 fake。如果测试只需要输入数据无需逻辑 —— stub 就足够了。如果测试检查方法被调用的事实 —— 需要 mock。在一个测试中混合 test doubles 类型会增加理解难度和脆弱性。
| 标准 | Fake | Stub | Mock |
|---|---|---|---|
| 拥有逻辑 | 是(简化) | 否 | 否 |
| 速度 | 高 | 最高 | 高 |
| 行为检查 | 间接 | 否 | 是(verify) |
| 维护 | 每个接口一个类 | 每个测试配置 | 每个测试配置 |
| 真实感 | 高(代码可运行) | 低(固定数据) | 中等 |
| 误报风险 | 低 | 中等 | 高(脆弱测试) |
反模式:不是 fake 的 Fake —— 开发人员将实际上是 stub 或 mock 的对象称为 fake 的常见错误。如果您的 InMemoryUserRepository 不包含逻辑(过滤、排序)—— 这不是 fake,而是带内存存储的 stub。Fake 与 stub 的区别恰恰在于具有可执行逻辑。如果 fake 仓库只是返回放入其中的内容而不处理数据 —— 请使用 mock 或 stub。
实用建议 —— 为每个仓库或服务从 fake 开始。如果 fake 超过 50 行 —— 分割成多个类。如果完全不需要 fake(测试仅检查一个固定数据的场景)—— 使用 stub。如果测试检查方法是否使用特定参数被调用 —— 使用 mock。不要预先优化选择:先写 fake,如果发现多余,在特定测试中用 stub 替换。
为 Room 创建 Fake 仓库 —— Android 上 fake 的典型示例。生产环境的 UserRepository 实现使用带有 SQLite 查询的 Room DAO。Fake 版本将数据存储在 MutableList 或 HashMap 中,并实现相同的方法:getUser(id)、saveUser(user)、deleteUser(id)。Fake 包含搜索、过滤和排序逻辑 —— 与生产仓库相同,但没有 SQL。这允许在无需配置 Room 数据库的情况下测试 ViewModel 和 UseCase。
class FakeUserRepository : UserRepository {
private val users = mutableListOf<User>()
override suspend fun getUser(id: String): User? {
return users.find { it.id == id }
}
override suspend fun saveUser(user: User) {
val index = users.indexOfFirst { it.id == user.id }
if (index >= 0) users[index] = user
else users.add(user)
}
override suspend fun search(query: String): List<User> {
return users.filter {
it.name.contains(query, ignoreCase = true)
}
}
}
为 Retrofit API 创建 Fake —— 可以创建一个 ApiService 实现,从内存集合返回数据,而不是 MockWebServer(它是 stub,不是 fake)。区别:MockWebServer 拦截 HTTP 并返回 JSON,而 fake-ApiService 在 Kotlin 接口级别工作,无需序列化。Fake 更快(无需解析 JSON),更易于调试(在同一进程中运行,类型安全)。适用于 HTTP 语义(状态码、标头)不重要的测试。
Swift 中的 Fake —— 通过协议构建。生产类使用真实逻辑(CoreData、URLSession)实现协议。Fake 结构使用内存存储和简化逻辑实现相同的协议。Swift 是具有值语义的语言,因此 fake 结构是不可变的,在多线程测试中是安全的。这比 Android 类似方案具有优势:无需同步对内存数据的访问。
protocol UserRepositoryProtocol {
func getUser(id: String) async -> User?
func saveUser(user: User) async
}
final class FakeUserRepository: UserRepositoryProtocol {
private var storage: [String: User] = [:]
func getUser(id: String) async -> User? {
return storage[id]
}
func saveUser(user: User) async {
storage[user.id] = user
}
}
final class UserViewModelTests: XCTestCase {
func test_save_and_load() async {
let fake = FakeUserRepository()
let vm = UserViewModel(repository: fake)
let user = User(id: "1", name: "Alice")
await vm.saveUser(user)
let loaded = await vm.getUser(id: "1")
XCTAssertEqual(loaded?.name, "Alice")
}
}
为 CoreData 创建 Fake —— 在 iOS 项目中,通过设置 description.type = NSInMemoryStoreType 可以创建内存 NSPersistentContainer。这是一个完整的 CoreData 栈,在内存中运行。这种 fake 允许在无需创建 SQLite 文件的情况下测试 NSFetchRequest、谓词和排序。速度:内存 CoreData 测试比磁盘类似方案快 5-10 倍。缺点:每次都需要配置 NSManagedObjectModel。
FakeURLProtocol —— URLProtocol 的子类,用于在 iOS 上拦截网络请求。通过 URLProtocol.registerClass(fakeProtocol) 注册。内部包含一个内存 URL -> Data 字典,无需真实请求即可返回数据。与 stub 的区别:FakeURLProtocol 可以检查请求体、标头,并根据输入数据返回不同的响应。这是 fake,因为它包含请求路由逻辑。
Fake 作为 Test Fixture —— 将 fake 类移到公共测试模块(androidTest/sharedTest 或 TestSupport)。项目中的所有测试使用相同的 InMemoryUserRepository。这消除了在每个测试中重复配置 mock 对象的问题,并保证了一致的行为。更改 fake 逻辑会同时更新所有测试。在 IT Sectr,我们将 fake 类存储在 sharedTest/java/com/itSectr/fake/ 中,并通过 implementation project(:sharedTest) 连接。
带预设数据的 Fake —— 测试经常需要已经包含某些记录的仓库。解决方案:工厂方法 fakeWithData(vararg items) 或内置方法 addDefaultData()。工厂创建 fake,用典型数据填充,并返回准备好使用的对象。这减少了测试中的样板代码:无需配置 mock 调用,测试只需调用 FakeUserRepository.withUsers(alice, bob)。
带调用计数的 Fake —— 有时不仅需要检查状态,还要检查调用次数。Fake 可以包含计数器:saveCallCount、getUserCallCount。测试在执行后检查计数器。这是纯 fake(状态检查)和 mock(交互检查)之间的折中。计数器不检查参数和调用顺序 —— 只检查次数。要检查参数,请使用 mock。
带 Callback 的 Fake —— 为了测试异步场景,fake 可以在每次调用时接受回调:beforeGetUser、afterSaveUser。这允许模拟延迟、错误或检查中间状态。这种方法对于测试 UI 加载状态很有用:fake 暂停 100 毫秒,测试检查屏幕是否显示加载器。在生产环境中,回调不存在 —— 这是纯粹的测试功能。
常见问题
Fake 包含工作逻辑 —— 过滤、排序、计数。Stub 只返回预定义的答案,没有逻辑。如果对象有分支(if/else、when)—— 这是 fake。如果它只包含 return values —— 这是 stub。Fake 维护成本更高,但提供更真实的测试。
当 fake 逻辑与生产逻辑不匹配时。例如,FakeUserRepository 使用区分大小写的搜索,而生产环境不区分大小写。测试通过,但实际上存在 bug。解决方案:单独测试 fake 逻辑,或仅对简单逻辑的接口(CRUD 操作)使用 fake。对于复杂逻辑,使用真实数据库编写集成测试。
内存数据库 —— fake 的一种变体。Room.inMemoryDatabaseBuilder() 创建一个行为类似生产数据库的内存 SQLite。这是一个完整的 fake。但 fake 也可以在仓库级别(无 SQL)和网络级别(FakeApiService)存在。内存数据库是 fake 的一种特殊情况,其逻辑最大程度地接近真实情况。
可以,但要谨慎。Fake 用于仓库(数据),Mock 用于 AnalyticsTracker(事件验证)。按层划分:fake 用于数据层,mock 用于分析/日志层。不要将同一个对象同时作为 fake 和 mock —— 这违反了单一职责原则并使测试混乱。
测试 fake 使用与生产实现相同的测试。如果您有检查 save、get、delete 的 UserRepositoryTest —— 运行两次:使用 FakeUserRepository 和 RealUserRepository。这保证了 fake 重复生产类的行为。如果 fake 开始表现不同 —— 测试将在两个实现上都失败。
总结
我们将开发一款交钥匙移动应用程序
IT Sectr自2017年以来为初创企业和企业打造iOS和Android应用程序。我们将为您提供咨询并提出最佳解决方案。