iOS 异步业务逻辑单元测试

举报
yd_217846120 发表于 2026/09/17 08:48:51 2026/09/17
【摘要】 iOS 异步业务逻辑单元测试异步业务逻辑容易受网络速度、回调顺序和任务取消影响。如果测试依赖真实服务或固定等待,不仅执行缓慢,还会偶发失败。稳定的单元测试应隔离外部依赖,主动控制结果与时序,并验证成功、失败、取消和乱序等路径。 一、把依赖抽象成小协议页面模型不直接创建网络客户端,而是依赖与业务相关的仓库协议:protocol ProfileRepository { func fetc...

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 和可暂停替身控制结果,通过注入时间构造边界,再主动验证取消和乱序路径,就能让异步业务逻辑既快速测试,也更容易发现真实竞态。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。