Swift:如何测试嵌套在定时器中的异步函数?
嘿,我懂你这种头疼的感觉——定时器里套异步任务,测试的时候总是赶在异步操作完成前就跑完断言,结果每次都失败对吧?别担心,Swift单元测试里有几种标准方法能解决这个问题,我结合你的代码给你拆解下:
方案1:给Mock API调度器加异步等待机制
你的核心问题是测试代码不知道异步上传什么时候完成,所以最直接的办法是给你的MockApiDispatcher加一个能让测试等待的方法,等上传操作完成后再执行断言。
先修改你的MockApiDispatcher:
class MockApiDispatcher { var sentToAPICount = 0 private let completionSemaphore = DispatchSemaphore(value: 0) // 这个方法在上传完成时调用(业务代码里要触发它) func markUploadCompleted() { sentToAPICount += 1 completionSemaphore.signal() } // 测试用的异步等待方法,超时时间可以按需调整 func waitForUploadCompletion(timeout: TimeInterval = 2) async throws { try await withCheckedThrowingContinuation { continuation in DispatchQueue.global().async { let result = self.completionSemaphore.wait(timeout: .now() + timeout) if result == .success { continuation.resume() } else { continuation.resume(throwing: NSError(domain: "TestError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Upload timed out"])) } } } } }
然后调整你的业务代码,在上传完成后调用这个标记方法:
self.timerProvider.scheduledTimer(withTimeInterval: timeBetweenAPISendsInSeconds, repeats: true) { _ in Task { do { _ = try await URLSession.shared.upload(for: request, from: encodedSessionBatch) self.apiDispatcher.markUploadCompleted() // 这里触发完成标记 } catch { // 可选:处理错误,也可以加错误标记方法 } } }
最后修改测试代码,先等待上传完成再断言:
func test_scheduledTimer_sendsSessionBatchToAPI() async throws { MockTimer.currentTimer.fire() try await mockApiDispatcher.waitForUploadCompletion() XCTAssertEqual(mockApiDispatcher.sentToAPICount, 1) }
方案2:用XCTestExpectation配合回调
如果不想用信号量,也可以用XCTest原生的XCTestExpectation,只需要给你的API调度器加一个可选的完成回调。
先给MockApiDispatcher加回调属性:
class MockApiDispatcher { var sentToAPICount = 0 var onUploadComplete: (() -> Void)? func markUploadCompleted() { sentToAPICount += 1 onUploadComplete?() } }
然后测试代码里创建预期,等待回调触发:
func test_scheduledTimer_sendsSessionBatchToAPI() { let uploadExpectation = XCTestExpectation(description: "API upload finishes") mockApiDispatcher.onUploadComplete = { uploadExpectation.fulfill() } MockTimer.currentTimer.fire() // 等待预期完成,超时时间设合理值 wait(for: [uploadExpectation], timeout: 5) XCTAssertEqual(mockApiDispatcher.sentToAPICount, 1) }
方案3:跟踪定时器里的Task并等待它完成
如果你的业务代码允许,可以让MockTimer捕获定时器触发时创建的Task,然后测试代码直接await这个Task的完成。
先修改MockTimer来跟踪Task:
class MockTimer { static let currentTimer = MockTimer() private(set) var lastTriggeredTask: Task<Void, Error>? private var storedBlock: ((Timer) -> Void)? func scheduledTimer(withTimeInterval interval: TimeInterval, repeats: Bool, block: @escaping (Timer) -> Void) -> Timer { storedBlock = block return Timer() // 返回空Timer就行,测试用不到实际定时器逻辑 } func fire() { storedBlock?(Timer()) } // 供业务代码调用,保存创建的Task func trackTask(_ task: Task<Void, Error>) { lastTriggeredTask = task } }
然后调整业务代码,创建Task后告诉MockTimer:
self.timerProvider.scheduledTimer(withTimeInterval: timeBetweenAPISendsInSeconds, repeats: true) { _ in let uploadTask = Task { _ = try await URLSession.shared.upload(for: request, from: encodedSessionBatch) mockApiDispatcher.sentToAPICount += 1 } // 只在测试环境下跟踪Task,避免污染生产代码 #if DEBUG (self.timerProvider as? MockTimer)?.trackTask(uploadTask) #endif }
最后测试代码里await这个Task:
func test_scheduledTimer_sendsSessionBatchToAPI() async throws { MockTimer.currentTimer.fire() if let task = MockTimer.currentTimer.lastTriggeredTask { try await task.value } XCTAssertEqual(mockApiDispatcher.sentToAPICount, 1) }
这三个方案里,方案1和2是比较通用的,不需要太多修改业务逻辑;方案3更适合需要精确控制Task生命周期的场景。你可以根据自己的代码结构选最顺手的那个。
内容的提问来源于stack exchange,提问作者PlankTon
相关产品推荐
相关产品推荐

