Stub(桩,测试替身)——一种测试对象,它返回预定义的响应而不是真实的实现。在移动开发中,桩将测试模块与网络请求、数据库和文件系统隔离开来,允许在无需配置环境的情况下检查逻辑。与mock不同,桩不验证行为——它只提供数据。更多信息请参阅Martin Fowler关于test doubles的文章。
要点
Stub——是一个替身对象,它在测试中替换真实依赖,并在特定调用时返回预先确定的值。该术语由Gerard Meszaros(2007)在其著作《xUnit Test Patterns》中引入分类。Stub属于test doubles类别——在测试期间替换真实组件的对象。桩的主要目的是为被测试块提供可预测的数据,消除外部系统的不确定性。
工作原理——测试在执行前配置桩:“当getUsers()方法被调用时,返回这个用户列表”。桩不包含业务逻辑,不检查调用顺序,也不记录访问历史。它只是站在真实组件的位置上,返回被告知的内容。在Android测试上下文中,这意味着OkHttp客户端不会向服务器发送真实请求,而是从配置为桩的MockWebServer接收响应。
何时使用——桩最适合测试UI层(ViewModel、Presenter)和业务逻辑(UseCase、Interactor),需要检查对特定数据的反应:列表为空、服务器返回500错误、令牌过期。任何测试需要特定输入状态的情况都是桩的任务。每个测试场景都有自己的桩配置,这使得测试可读且可预测。
Gerard Meszaros(2007)在《xUnit Test Patterns》一书中确定了五种test doubles类型:dummy、stub、spy、mock、fake。每种类型解决自己的任务。Dummy——被传递但不使用。Stub——返回数据。Spy——记录调用。Mock——验证行为。Fake——包含简化的逻辑。理解这种分类有助于开发人员为每个测试场景选择正确的工具。
网络请求——最常用的桩场景。应用程序向API发出HTTP调用,在测试中需要检查对不同响应的反应:成功的JSON、401错误(未授权)、超时、空数组。Android上的MockWebServer(OkHttp)和iOS上的URLProtocol充当桩,无需真实连接到服务器即可返回预定义的HTTP响应。这将测试从秒级加速到毫秒级。
数据库——Room(Android)和CoreData(iOS)有内存中变体,但配置它们仍然需要时间。桩代替存储库返回预先准备好的Entity列表,无需触及数据库。这对ViewModel的测试特别有效,需要检查排序、过滤或数据转换。测试在毫秒内执行,与数据量无关。
系统服务——LocationManager、SensorManager、SharedPreferences需要真实设备或模拟器。LocationProvider的桩返回指定坐标,SensorManager的桩返回固定的加速度计值。在iOS上,类似的是具有测试委托实现的CLLocationManager。没有桩,这样的测试需要具有特定条件的物理设备。
文件系统和缓存——加载图像、缓存响应、处理配置文件——所有这些操作都依赖于磁盘状态。FileManager或ImageCache的桩在不读取真实文件的情况下返回成功/错误。这消除了不同开发机器上由于路径或权限不匹配而导致的测试误失败。
职责划分——三种test doubles解决不同的任务。Stub:“给我数据”。Mock:“检查我是否被调用”。Fake:“我像真实的一样工作,只是更简单”。这一区别对测试的可读性至关重要:如果测试在需要桩的地方使用了mock,它会被与测试场景无关的verify调用所过载。
| 特征 | Stub | Mock | Fake |
|---|---|---|---|
| 目的 | 提供数据 | 验证交互 | 简化实现 |
| 逻辑 | 无 | 无 | 有(但简化) |
| 验证 | 无 | 有(verify) | 间接(通过状态) |
| 灵活性 | 低——固定响应 | 中 | 高——逻辑自适应 |
| 速度 | 最高 | 高 | 中 |
| 示例 | MockWebServer返回JSON | Mockito.verify(repository).save() | 使用HashMap的InMemoryRepository |
实践经验——如果测试检查被测组件接收了什么数据——使用桩。如果测试检查组件是否使用正确的参数调用了依赖方法——使用mock。如果只是想用哈希表替换数据库——这是fake。在一个测试中混合类型使其脆弱:当实现更改时,需要重写桩和verify逻辑。
带verify的Stub——当开发人员配置桩然后添加verify(stub).method()时的常见错误。根据定义,桩不应被验证——验证有mock。如果需要检查方法是否使用特定参数被调用,请使用Mockito.mock()而不是Mockito.stub()。这种分离保持了测试意图对其他开发人员的清晰性。
MockWebServer——用于在Android和JVM上创建HTTP桩的OkHttp库。它在指定端口上启动一个本地HTTP服务器,拦截OkHttp客户端的请求并返回预定义的响应。设置只需三行:创建服务器、入队响应、启动。测试可以连续入队多个响应,用于分页或重试场景。
class UserRepositoryTest {
private val server = MockWebServer()
fun setup() {
server.start(8080)
val client = OkHttpClient.Builder()
.readTimeout(1, TimeUnit.SECONDS)
.build()
}
fun test_user_list_success() {
val json = "[{\"id\":1,\"name\":\"Alice\"}]"
server.enqueue(MockResponse()
.setBody(json)
.setResponseCode(200)
)
val result = repository.getUsers()
assertEquals(1, result.size)
}
fun teardown() {
server.shutdown()
}
}
MockK——Mockito的替代品,用于Kotlin,具有对协程、扩展函数和密封类的一流支持。MockK中的桩通过coEvery(用于挂起函数)和every(用于普通函数)创建。与MockWebServer不同,MockK替换单独的依赖方法,而不是整个HTTP层。这对于UseCase或Interactor的单元测试很方便,其中依赖项是存储库的抽象。
interface UserRepository {
suspend fun getUsers(): List<User>
}
class GetUsersUseCaseTest {
private val repo = mockk<UserRepository>()
private val useCase = GetUsersUseCase(repo)
fun test_empty_list() = runTest {
coEvery { repo.getUsers() } returns emptyList()
val result = useCase.invoke()
assertTrue(result.isEmpty())
coVerify(exactly = 1) { repo.getUsers() }
}
}
最佳实践——对于集成测试,使用MockWebServer(拦截真实HTTP);对于单元测试,使用MockK(替换接口)。不要替换你不测试的内容:如果测试检查Repository,不要替换其中的OkHttp客户端——在HTTP级别使用真正的MockWebServer。这条规则保持测试的相关性,并在重构时降低脆弱性。
Swift协议作为桩——在iOS原生方法中,桩通过插入符合依赖协议的测试结构来实现。测试接收StubNetworkService而不是真正的NetworkService,它返回固定数据。Swift是一种静态类型语言,因此桩必须符合与真实服务相同的协议。编译器保证桩实现所有必需的方法。
protocol NetworkServiceProtocol {
func fetchUsers() async throws -> [User]
}
struct StubNetworkService: NetworkServiceProtocol {
let result: Result<[User], Error>
func fetchUsers() async throws -> [User] {
try result.get()
}
}
final class UsersViewModelTests: XCTestCase {
func test_success_state() async {
let stub = StubNetworkService(
result: .success([User(name: "Alice")])
)
let vm = UsersViewModel(service: stub)
await vm.load()
XCTAssertEqual(vm.users.count, 1)
}
}
用于Objective-C的OCMock——用于在遗留iOS项目中创建桩和mock的库。OCMock支持带有参数和返回值的桩方法。现代的Swift项目更喜欢基于协议的手动桩方法——这提供了对每个方法的控制,并且不需要外部依赖。OCMock仍然是那些将所有依赖协议化在经济上不可行的项目的选择。
用于HTTP桩的URLProtocol——iOS的系统机制,通过URLProtocol子类拦截网络请求。测试注册一个自定义URLProtocol,它拦截URLSession并返回桩响应。与手动桩相比的优点:无需更改应用程序架构——URLSession保持真实,但数据在协议级别被替换。缺点:比显式桩服务更难调试。
常见问题
Stub返回预定义数据,不检查调用事实。Mock额外验证方法是否使用正确的参数被调用(verify)。Stub回答“返回什么”的问题,Mock回答“是否被调用”的问题。使用桩检查状态,使用mock检查交互。
Fake在测试需要可行的(即使简化了)实现时需要——例如,使用内存数据库代替Room。Stub适用于具有预定义数据的单一场景。如果你在10个测试中重复相同的桩——很可能你需要Fake。Fake减少了重复,因为逻辑存在于一个类中。
在Android上——MockK对于Kotlin对象(object)支持mockkObject(),包括通过mockkStatic()的Java类的静态方法。在iOS上——Swift的静态方法不能直接桩化;使用协议和DI将static调用替换为协议的实例方法。静态桩是技术债务,应在新代码中避免。
使用MockWebServer(OkHttp)——它作为一个本地HTTP服务器工作,将响应入队(enqueue)。对于Retrofit,只需将基本URL更改为localhost:8080。对于Ktor,使用MockEngine——用于替换HttpStatement的内置机制。两种方法都无需真实互联网即可工作,并提供了对响应状态码、正文和标头的完全控制。
Spy包装真实对象并记录调用,而Stub完全用固定响应替换对象。Spy允许部分使用真实实现(其他方法照常工作),而桩则不允许。如果需要检查方法被调用,但部分逻辑应执行——使用spy,而不是stub。
总结
我们将开发一款交钥匙移动应用程序
IT Sectr自2017年以来为初创企业和企业打造iOS和Android应用程序。我们将为您提供咨询并提出最佳解决方案。