iOS 异步业务逻辑单元测试
iOS 异步业务逻辑单元测试
异步业务逻辑容易受网络速度、回调顺序和任务取消影响。如果测试依赖真实服务或固定等待,不仅执行缓慢,还会偶发失败。稳定的单元测试应隔离外部依赖,主动控制结果与时序,并验证成功、失败、取消和乱序等路径。
一、把依赖抽象成小协议
页面模型不直接创建网络客户端,而是依赖与业务相关的仓库协议:
protocol ProfileRepository {
func fetchProfile() async throws -> Profile
}
@MainActor
final class ProfileViewModel {
enum State: Equatable {
case idle
case loading
case content(Profile)
case failed(String)
}
private(set) var state: State = .idle
private let repository: ProfileRepository
init(repository: ProfileRepository) {
self.repository = repository
}
func load() async {
state = .loading
do {
state = .content(try await repository.fetchProfile())
} catch is CancellationError {
return
} catch {
state = .failed("加载失败")
}
}
}
协议只包含测试目标实际使用的能力,替身就能保持简单。
二、使用结果可控的 Stub
final class ProfileRepositoryStub: ProfileRepository {
var result: Result<Profile, Error>
private(set) var fetchCount = 0
init(result: Result<Profile, Error>) {
self.result = result
}
func fetchProfile() async throws -> Profile {
fetchCount += 1
return try result.get()
}
}
成功测试可以直接注入模型,失败测试注入确定错误。测试不受真实网络、账户和时间影响。
三、测试状态转换
@MainActor
func testLoadSuccessShowsProfile() async {
let profile = Profile(id: "user-1", nickname: "小林")
let repository = ProfileRepositoryStub(result: .success(profile))
let viewModel = ProfileViewModel(repository: repository)
await viewModel.load()
XCTAssertEqual(viewModel.state, .content(profile))
XCTAssertEqual(repository.fetchCount, 1)
}
除了最终数据,还可以验证调用次数和关键参数,确认页面模型没有重复请求。
如果需要观察 .loading 中间状态,立即返回的 Stub 可能太快。此时应使用可暂停替身,而不是增加固定延迟。
四、用 Continuation 控制完成时机
final class ControlledProfileRepository: ProfileRepository {
private var continuation: CheckedContinuation<Profile, Error>?
func fetchProfile() async throws -> Profile {
try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
}
}
func succeed(with profile: Profile) {
continuation?.resume(returning: profile)
continuation = nil
}
func fail(with error: Error) {
continuation?.resume(throwing: error)
continuation = nil
}
}
测试可以先启动 load,断言状态为加载中,再由测试主动完成请求。每个 continuation 必须且只能恢复一次,替身实现也要遵守这个约束。
五、测试取消语义
取消不应被显示为普通失败。测试可以创建任务、等待仓库进入挂起状态,再取消任务并验证最终状态。
底层替身必须感知取消,否则测试只证明上层不接收结果,不能证明资源真正停止。可以在替身内部使用取消处理器记录取消回调是否发生。
final class CancellationRecorder {
private(set) var cancelled = false
func record() {
cancelled = true
}
}
具体取消桥接方式应与项目最低系统版本和当前并发封装保持一致。
六、测试请求乱序
搜索页面需要保证新请求结果不会被旧请求覆盖。可以让替身为每次请求保存独立 continuation,然后先完成第二次请求,再完成第一次。
断言应关注最终状态仍对应第二个关键词,同时确认第一次任务已取消或结果被拒绝。这样可以稳定复现线上很难偶然遇到的竞态。
七、注入时间与标识生成器
过期判断、倒计时和重试通常依赖当前时间。直接读取系统时间会让边界测试不稳定,可以注入简单时钟:
protocol ClockProviding {
var now: Date { get }
}
struct FixedClock: ClockProviding {
let now: Date
}
随机标识、地区和系统配置也可以按相同思路抽象,但只抽象确实影响测试的依赖,避免为每个系统调用增加无意义包装。
八、避免固定等待
在测试中等待固定秒数既慢又不稳定。设备忙时等待不够会失败,设备快时又浪费时间。优先等待明确状态、回调或期望条件,并设置合理超时作为失败边界。
测试超时是故障保护,不应成为业务时序本身。测试的正常路径应该由替身主动推进。
九、区分 Stub、Spy 与 Fake
- Stub 返回预设结果,用于控制输入。
- Spy 记录调用次数和参数,用于验证交互。
- Fake 提供简化但可运行的实现,例如内存仓库。
一个替身可以同时具备多种能力,但命名应表达主要用途。没有必要引入比被测逻辑更复杂的模拟框架。
十、保持测试聚焦
单个测试只验证一个清晰行为,名称描述条件和结果。建议覆盖:
- 成功数据映射。
- 空数据状态。
- 可展示的业务错误。
- 取消不产生错误提示。
- 重试不会同时运行多个请求。
- 乱序完成只接收最新结果。
- 页面或模型释放后任务停止。
总结
可靠的异步单元测试依靠可控性,而不是等待。把外部能力收敛为小协议,用 Stub 和可暂停替身控制结果,通过注入时间构造边界,再主动验证取消和乱序路径,就能让异步业务逻辑既快速测试,也更容易发现真实竞态。
- 点赞
- 收藏
- 关注作者
评论(0)