You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Swift Async/Await与Combine Publisher混合代码单元测试问题求助

解决Combine与Async/Await混合代码的单元测试问题

核心思路

解决这类问题的关键是消除异步操作的不确定性,通过依赖注入和测试专用工具,让Combine的调度逻辑和Async/Await的执行流程都处于可控状态,彻底摆脱对真实时间等待或随机线程调度的依赖。


具体解决方案

1. 注入可测试的调度器(Scheduler)

把硬编码的DispatchQueue.main替换为可注入的Scheduler,测试时使用Combine的TestScheduler手动推进时间,完全控制debounce的触发时机,不用等待真实的1秒。

改造原业务代码:

// 添加可注入的调度器属性,默认使用主队列
var scheduler: Scheduler = DispatchQueue.main

lazy var searchTests: AnyPublisher<Result<LabTestResultPage, Error>, Never> = searchSub
      .compactMap { $0 }
      .debounce(for: .seconds(1), scheduler: scheduler) // 使用注入的调度器
      .map { [unowned self] in search(searchParams: $0) }
      .switchToLatest()
      .share()
      .eraseToAnyPublisher()

测试中的调度器用法:

func testSearchTests() async {
    // 初始化测试专用调度器
    let testScheduler = TestScheduler(initialClock: 0)
    
    // 创建被测对象并注入测试调度器
    let viewModel = YourViewModel()
    viewModel.scheduler = testScheduler
    
    // 创建Mock UseCase,预设Async/Await的返回结果
    let mockUseCase = MockSearchTestsUseCase()
    mockUseCase.stubbedResult = .success(mockTestPage)
    viewModel.searchTestsUseCase = mockUseCase
    
    // 订阅Publisher,捕获输出结果
    var receivedResult: Result<LabTestResultPage, Error>?
    let cancellable = viewModel.searchTests
        .sink { result in
            receivedResult = result
        }
    
    // 发送测试用的搜索参数
    viewModel.searchSub.send(SearchTestsParams(query: "test"))
    
    // 手动推进时间到debounce结束点(1秒)
    testScheduler.advance(by: .seconds(1))
    
    // 断言结果符合预期
    XCTAssertEqual(receivedResult, .success(mockTestPage))
}

2. 抽象并Mock Async/Await依赖

将searchTestsUseCase抽象为协议,测试时使用Mock实现,让Async/Await方法可以同步返回预设结果,彻底消除Task执行的不确定性,再也不用依赖Task.sleep。

定义协议:

protocol SearchTestsUseCaseProtocol {
    func execute(with params: SearchTestsParams) async throws -> LabTestResultPage
}

// 原业务用例遵循协议
class SearchTestsUseCase: SearchTestsUseCaseProtocol {
    func execute(with params: SearchTestsParams) async throws -> LabTestResultPage {
        // 原业务逻辑实现
    }
}

实现Mock UseCase:

class MockSearchTestsUseCase: SearchTestsUseCaseProtocol {
    var stubbedResult: Result<LabTestResultPage, Error>?
    
    func execute(with params: SearchTestsParams) async throws -> LabTestResultPage {
        switch stubbedResult {
        case .success(let page):
            return page
        case .failure(let error):
            throw error
        case nil:
            fatalError("请预先设置Mock返回结果")
        }
    }
}

3. 用Async/Await风格测试简化断言

XCTest原生支持异步测试方法,结合Combine的values属性(iOS15+)或自定义异步捕获逻辑,可以更简洁地等待结果,避免复杂的XCTestExpectation配置:

func testSearchTestsAsync() async {
    let testScheduler = TestScheduler(initialClock: 0)
    let viewModel = YourViewModel()
    viewModel.scheduler = testScheduler
    
    let mockUseCase = MockSearchTestsUseCase()
    mockUseCase.stubbedResult = .success(mockTestPage)
    viewModel.searchTestsUseCase = mockUseCase
    
    // 发送搜索参数
    viewModel.searchSub.send(SearchTestsParams(query: "test"))
    // 推进时间触发debounce
    testScheduler.advance(by: .seconds(1))
    
    // 等待Publisher发出第一个值(iOS15+支持)
    let result = await viewModel.searchTests.values.first!
    XCTAssertEqual(result, .success(mockTestPage))
}

4. 优化Async/Await转Combine的逻辑(可选)

原代码中Deferred+Future+Task的组合可以简化为更简洁的实现,同时保持可测试性:

private func search(searchParams: SearchTestsParams) -> AnyPublisher<Result<LabTestResultPage, Error>, Never> {
    Task {
        do {
            return .success(try await searchTestsUseCase.execute(with: searchParams))
        } catch {
            return .failure(error)
        }
    }
    .asPublisher() // iOS15+可用,或自行实现Task转Publisher的扩展
    .eraseToAnyPublisher()
}

关键注意事项

  • 所有涉及异步调度的逻辑(比如debounce、Task执行)都要避免硬编码,通过依赖注入替换为测试专用实现。
  • 绝对不要用Task.sleep来等待异步操作完成,这会导致测试不稳定,因为真实线程调度时间不可控。
  • 确保Mock对象的行为完全可控,预设所有可能的返回结果(成功、失败),覆盖不同测试场景。

内容的提问来源于stack exchange,提问作者Genhain

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.30 20:45:39