搜索框设了 300 毫秒防抖,测试却写成“输入后等待半秒,再检查请求次数”。在云端 Mac 的并行任务中,这种测试既慢,也容易被调度延迟干扰。真正需要验收的是:最后一次输入后的截止点、旧输入被替换、清空后不再交付。把这些规则从计时器中拆出来,测试就不必等待现实时间。
先写清搜索的时间契约
本文采用尾沿防抖:每次非空输入都重新计算截止点,到点只交付最新搜索词一次。首尾空白先移除;纯空白与空字符串都取消待交付内容。同一个词再次输入也重新计时,这是明确的产品规则,不是所有实现的默认行为。
时间用从测试起点开始的整数毫秒表示,不是系统日期。生产环境应从单调时钟取得经过时间,不能用可能因校时跳变的墙上时间。300 毫秒只是示例策略,不是服务性能指标。
防抖只决定什么时候交付搜索词。已经发出的网络请求是否取消、旧响应能否更新界面,是另外两条契约,不能用“定时器取消了”代替验收。
把计时规则写成独立状态机
在云端 Mac 命令行建立一个小型验证包。当前选中的开发工具链需要包含 Swift;这里不依赖模拟器或图形会话。
mkdir SearchTiming
cd SearchTiming
swift package init --type library --name SearchTiming
将 Sources/SearchTiming/SearchTiming.swift 替换为以下内容。它没有创建线程或定时器,调用者负责按非递减顺序传入时间,并串行访问状态。
import Foundation
struct SearchDebounce {
let delay: Int64
private(set) var deadline: Int64?
private var pending: String?
init(delay: Int64 = 300) {
precondition(delay > 0)
self.delay = delay
}
mutating func input(_ text: String, at now: Int64) {
let query = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else {
pending = nil
deadline = nil
return
}
pending = query
deadline = now + delay
}
mutating func takeDue(at now: Int64) -> String? {
guard let limit = deadline, now >= limit else {
return nil
}
let query = pending
pending = nil
deadline = nil
return query
}
}
takeDue 同时检查截止点并消费待交付内容。因此重复回调不会重复交付,过早回调也不会把内容清掉。示例假设时间值处于正常运行范围;若时钟适配器接受外部数值,还应检查负值、倒退与加法溢出。
推进虚拟时间,不调用 sleep
将 Tests/SearchTimingTests/SearchTimingTests.swift 替换为下面的测试。每个用例都新建状态,避免共享时钟与待处理输入。
import XCTest
@testable import SearchTiming
final class SearchTimingTests: XCTestCase {
func testDeadlineAndSingleDelivery() {
var state = SearchDebounce()
state.input(" swift ", at: 0)
XCTAssertNil(state.takeDue(at: 299))
XCTAssertEqual(state.takeDue(at: 300), "swift")
XCTAssertNil(state.takeDue(at: 301))
}
func testLatestInputMovesDeadline() {
var state = SearchDebounce()
state.input("s", at: 0)
state.input("sw", at: 100)
state.input("swift", at: 220)
XCTAssertEqual(state.deadline, 520)
XCTAssertNil(state.takeDue(at: 300))
XCTAssertNil(state.takeDue(at: 519))
XCTAssertEqual(state.takeDue(at: 520), "swift")
}
func testBlankCancelsPendingInput() {
var state = SearchDebounce()
state.input("swift", at: 0)
state.input(" \n ", at: 100)
XCTAssertNil(state.deadline)
XCTAssertNil(state.takeDue(at: 1000))
}
func testSameInputRestartsDelay() {
var state = SearchDebounce()
state.input("swift", at: 0)
state.input("swift", at: 200)
XCTAssertNil(state.takeDue(at: 300))
XCTAssertEqual(state.takeDue(at: 500), "swift")
}
}
运行 swift test。断言失败必须使命令返回非零,流水线不能只归档输出后继续。测试覆盖了截止前一刻、恰好截止和重复消费;再补一个明显晚于截止点的用例,可验证调度延迟不会导致内容永久丢失。
接入真实任务时防住两类取消错误
实际接线可以用 ContinuousClock 等待截止点。状态机负责规则,适配器负责唤醒。每次输入先更新状态,再取消旧等待任务,为新的截止点建立等待;清空后不再创建任务。
第一类错误是吞掉取消异常。try? 等待结束后直接调用交付逻辑,会让取消路径继续执行。应在捕获取消时退出,不能把取消当作“时间已经到”。
第二类错误是旧任务迟到。为每次输入增加代次编号,等待任务保存创建时的代次;醒来后,在同一个串行执行器上比较当前代次,匹配才允许消费状态。仅检查任务是否被取消不够,因为取消与唤醒可能同时发生。
界面输入、代次更新和结果交付可统一放在主执行器。不要在后台读取状态后,再异步跳回主执行器修改它,否则检查与消费之间会出现空隙。虚拟测试中的毫秒只是便于断言;真实等待应使用时钟的期限与时长类型,避免累积换算误差。
用少量接线测试收口
纯逻辑测试通过后,再检查真实适配器,而不是把所有用例改回长时间等待。设置足够宽的测试超时,但仍检查不提前交付和最终只交付一次;超时只负责发现卡住,不应作为精确时间断言。
验收清单至少包含:快速连续输入只交付末项;清空立即撤销待交付项;取消异常不进入交付路径;旧代次回调被丢弃;界面释放后不再更新;连续运行多轮结果一致。若业务需要相同搜索词去重,应单独增加去重层,并明确清空后重输是否允许再次搜索。
保留提交版本、工具链版本与测试退出码即可,不必记录用户真实搜索内容。最终拿到的不是“等待半秒大概能过”的脚本,而是一组可以指出哪条时序规则被破坏的断言。
常见问题
虚拟时间测试能完全替代真实定时器测试吗?
不能。虚拟时间验证状态机的截止与取消规则;还需少量集成测试确认实际调度器使用单调时钟、取消正确,并在规定的执行器上交付结果。
清空搜索框后,已经启动的请求也会自动取消吗?
不会。本文状态机只取消尚未交付的搜索词。已启动请求需要网络层单独取消,并用请求代次校验阻止旧结果回写。