如何测试防抖逻辑?关于无等待测试方案及计时器稳定性的疑问
如何测试防抖逻辑?关于无等待测试方案及计时器稳定性的疑问
嗨,我来帮你拆解这两个问题,结合你的代码一起分析~
问题1:有没有办法不用等待指定时间来测试?
你的思路是尽量测试外部行为、减少对内部实现的依赖,这点非常棒!如果不想用Mock Debouncer,其实可以通过抽象时间依赖的方式来绕过真实等待,同时又不会让测试过度绑定防抖实现:
我们可以定义一个TimeProvider协议,让ViewModel依赖它来处理延迟,而不是直接调用Task.sleep。这样在测试时,我们可以用一个“假”的时间提供者,直接跳过等待逻辑:
// 定义时间提供者协议 protocol TimeProvider { func sleep(nanoseconds: UInt64) async throws } // 生产环境的真实实现 class DefaultTimeProvider: TimeProvider { func sleep(nanoseconds: UInt64) async throws { try await Task.sleep(nanoseconds: nanoseconds) } } // 测试环境的模拟实现 class TestTimeProvider: TimeProvider { var sleepCalled = false var recordedDuration: UInt64? func sleep(nanoseconds: UInt64) async throws { sleepCalled = true recordedDuration = nanoseconds // 立刻返回,不做真实等待 } }
然后修改你的SearchViewModel,让它依赖这个协议:
struct SearchViewModel { let urlSession: URLSessionProtocol let searchDebounce: Double let timeProvider: TimeProvider // 初始化时给默认值,不影响生产代码 init(searchApiService: URLSessionProtocol = URLSession.shared, searchDebounce: Double, timeProvider: TimeProvider = DefaultTimeProvider()) { self.urlSession = searchApiService self.searchDebounce = searchDebounce self.timeProvider = timeProvider } func search(for searchTerm: String) { Task { // 替换成调用timeProvider的sleep try? await timeProvider.sleep(nanoseconds: UInt64(searchDebounce * 1_000_000_000)) // 后续请求逻辑不变... } } }
这样测试时,你可以:
- 验证
timeProvider.sleep是否被调用,并且传入的时长和设定的防抖时间一致 - 因为测试用的
TimeProvider会立刻返回,你可以用XCTestExpectation等待请求触发,完全不用真实等待
这种方式既保持了测试对外部行为的关注,又避免了等待,同时以后如果要更换防抖实现(比如换成Combine的debounce),只要修改TimeProvider的实现就行,测试代码不用动。
如果坚持完全不修改ViewModel的依赖,只测试外部行为,那确实只能等待,但可以用更可靠的方式——比如用XCTestExpectation监听请求触发事件,而不是固定等待时长(这点在问题2里详细说)。
问题2:为什么0.21秒有时候不够,需要0.22?
这是系统任务调度的不确定性导致的:
Task.sleep的语义是“至少等待指定时长”,而不是“精确等待”。如果系统当时负载高、有其他任务在占用线程,你的ViewModel里的防抖任务可能会被推迟唤醒,实际完成时间会略长于0.2秒。- 测试代码里的
Task.sleep和ViewModel里的Task.sleep是两个独立的异步任务,它们的调度顺序由系统决定,即使你设置了0.21秒的等待,也不能保证ViewModel的任务已经完成。
解决这个flaky问题的最佳方式是放弃固定等待时长,用期望(Expectation)监听实际的请求触发事件:
修改你的SpyUrlSession,增加一个回调来通知测试请求已触发:
class SpyUrlSession: URLSessionProtocol { var dataTaskCallCount = 0 var lastSentRequest: URLRequest? var onRequestTriggered: (() -> Void)? // 新增回调 func data(for request: URLRequest) async throws -> (Data, URLResponse) { dataTaskCallCount += 1 lastSentRequest = request onRequestTriggered?() // 触发回调 return (Data(), URLResponse()) } }
然后修改测试代码,用XCTestExpectation等待这个事件:
func test_whenSearchingForString_networkRequestIsFiredAfterGivenTime() async throws { let spyNetworkService = SpyUrlSession() let sut = SearchViewModel(searchApiService: spyNetworkService, searchDebounce: 0.2) let requestExpectation = XCTestExpectation(description: "网络请求应在防抖后触发") // 绑定回调,请求触发时完成期望 spyNetworkService.onRequestTriggered = { requestExpectation.fulfill() } sut.search(for: "My test string") XCTAssertEqual(spyNetworkService.dataTaskCallCount, 0, "请求不应立即触发") // 等待期望完成,超时时间设为1秒(足够覆盖防抖时间和调度延迟) await fulfillment(of: [requestExpectation], timeout: 1.0) XCTAssertEqual(spyNetworkService.dataTaskCallCount, 1) }
这样测试会一直等待,直到请求实际触发,完全避免了固定等待时长带来的不确定性,再也不会出现有时候0.21秒够、有时候不够的问题。
备注:内容来源于stack exchange,提问作者ADB
相关产品推荐
相关产品推荐

