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
相关产品推荐
相关产品推荐

