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

如何测试防抖逻辑?关于无等待测试方案及计时器稳定性的疑问

如何测试防抖逻辑?关于无等待测试方案及计时器稳定性的疑问

嗨,我来帮你拆解这两个问题,结合你的代码一起分析~


问题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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.04.15 09:59:35