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

Swift XCTest中如何测试含异步任务的Interactor方法?

Swift XCTest异步任务测试的正确姿势(VIP架构场景)

问题场景

我在使用VIP(Clean Swift)架构开发Swift应用,Interactor中的viewDidLoad方法由ViewController触发,内部会启动异步任务获取数据,最终通过@MainActor修饰的方法调用Presenter的presentData更新UI。现在需要测试:当调用Interactor的viewDidLoad时,Presenter的presentData是否被正确调用。

当前的Interactor简化代码:

protocol MyInteractorProtocol {
    func viewDidLoad()
}

protocol MyPresenterProtocol {
    func presentData()
}

class MyInteractor: MyInteractorProtocol {
    private var presenter: MyPresenterProtocol

    init(presenter: MyPresenterProtocol) {
        self.presenter = presenter
    }

    func viewDidLoad() {
        Task {
            await fetchData()
        }
    }

    private func fetchData() async {
        // 模拟数据获取逻辑
        await updateUI()
    }

    @MainActor
    private func updateUI() {
        presenter.presentData()
    }
}

我尝试过两种测试方式,但都有缺陷:

  • 用Task.sleep()硬编码等待时长,测试效率低且不稳定;
  • 用XCTestExpectation但实现不规范,容易出现预期提前触发导致断言失败的问题。

解决方案

方案1:让Mock Presenter绑定XCTestExpectation(推荐)

这种方式直接把测试预期和Presenter的目标方法绑定,只有当presentData被真正调用时才触发预期,完全避免了依赖等待时间的问题,测试稳定性最高。

首先实现带预期触发的Mock Presenter:

class MockPresenter: MyPresenterProtocol {
    var presentDataCalled = false
    var completionExpectation: XCTestExpectation?

    func presentData() {
        presentDataCalled = true
        completionExpectation?.fulfill()
    }
}

然后编写测试用例:

import XCTest
@testable import YourAppModule

final class MyInteractorTests: XCTestCase {
    func testViewDidLoad_CallsPresenterPresentData() {
        // 初始化Mock和Interactor
        let mockPresenter = MockPresenter()
        let interactor = MyInteractor(presenter: mockPresenter)
        
        // 创建测试预期
        let expectation = self.expectation(description: "Presenter should call presentData")
        mockPresenter.completionExpectation = expectation
        
        // 触发测试方法
        interactor.viewDidLoad()
        
        // 等待预期触发,超时时间设为合理值(比如5秒)
        waitForExpectations(timeout: 5)
        // 执行断言
        XCTAssertTrue(mockPresenter.presentDataCalled)
    }
}

方案2:利用异步测试函数+状态等待

如果不想修改Mock Presenter的结构,可以在异步测试函数中,通过循环配合Task.yield()等待目标状态变化,同时添加超时保护避免无限等待。

测试代码如下:

func testViewDidLoad_CallsPresenterPresentData_AsyncAwait() async {
    let mockPresenter = MockPresenter()
    let interactor = MyInteractor(presenter: mockPresenter)
    
    // 触发测试方法
    interactor.viewDidLoad()
    
    // 启动超时任务,防止测试无限挂起
    let timeoutTask = Task {
        try await Task.sleep(nanoseconds: 5_000_000_000)
        XCTFail("Test timed out waiting for presentData")
    }
    
    // 等待presentDataCalled变为true,每次循环让出线程等待异步任务执行
    while !mockPresenter.presentDataCalled {
        try await Task.yield()
    }
    
    // 取消超时任务,执行断言
    timeoutTask.cancel()
    XCTAssertTrue(mockPresenter.presentDataCalled)
}

方案3:暴露Interactor的异步任务(需修改业务代码)

如果允许为测试调整业务代码,可以在Interactor中暴露异步任务的引用,直接等待任务完成后再执行断言。

修改后的Interactor代码:

class MyInteractor: MyInteractorProtocol {
    private var presenter: MyPresenterProtocol
    var fetchDataTask: Task<Void, Never>? // 测试专用属性
    
    init(presenter: MyPresenterProtocol) {
        self.presenter = presenter
    }

    func viewDidLoad() {
        fetchDataTask = Task {
            await fetchData()
        }
    }

    // 其他代码不变
}

对应的测试用例:

func testViewDidLoad_CallsPresenterPresentData_WithExposedTask() async {
    let mockPresenter = MockPresenter()
    let interactor = MyInteractor(presenter: mockPresenter)
    
    interactor.viewDidLoad()
    
    // 等待异步任务完成
    await interactor.fetchDataTask?.value
    
    // 执行断言
    XCTAssertTrue(mockPresenter.presentDataCalled)
}

总结

  • 优先选择方案1,无需修改业务代码,测试逻辑清晰且稳定性最高;
  • 如果不能修改业务代码,方案2是更灵活的替代方案;
  • 方案3适合允许为测试调整代码的场景,测试逻辑最简洁。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.18 08:32:33