Swift中ConcurrencyExtras优化异步测试执行时间的原理及方案
问题背景
团队在iOS单元测试中重度依赖ConcurrencyExtras工具包的withMainSerialExecutor,用于将异步测试代码调度至主线程,并通过await Task.yield()等待耗时操作完成。但测试总耗时持续增加,后来通过在每个测试类中重写invokeTest()并移除测试方法内的withMainSerialExecutor包裹,异步测试执行速度显著提升——例如某测试耗时从0.11秒降至快速执行水平。
重写的核心代码
override func invokeTest() { withMainSerialExecutor { super.invokeTest() } }
测试代码对比
原版本(耗时0.11秒)
func testHasLoaded() async { withMainSerialExecutor { /// Arrange sut = makeVM() var hasLoaded = String() sut.dataHasFinishedLoading = { hasLoaded = "hasLoaded" } /// Assert await Task.megaYield() XCTAssertEqual(hasLoaded, "hasLoaded") } }
修改后版本(快速执行)
func testHasLoaded() async { /// Arrange sut = makeVM() var hasLoaded = String() sut.dataHasFinishedLoading = { hasLoaded = "hasLoaded" } /// Assert await Task.megaYield() XCTAssertEqual(hasLoaded, "hasLoaded") }
核心疑问
两种方式本质都是强制测试在主线程执行,为何耗时差异明显?还有哪些缩短异步测试耗时的方案?
为什么全局重写invokeTest()更快?
核心差异在于线程调度的重复开销与任务嵌套层级,具体体现在三点:
避免重复线程上下文切换
原写法中每个测试方法单独调用withMainSerialExecutor,意味着每个测试都要经历「测试线程→主线程→测试线程」的两次上下文切换。而全局重写invokeTest()后,整个测试类的所有测试都直接在主线程启动,仅需一次全局调度切换,消除了每个测试的重复切换开销。减少异步任务的嵌套调度开销
withMainSerialExecutor内部会将传入的代码块包装成任务提交到主线程串行队列。当在异步测试方法中嵌套调用它时,相当于把整个异步测试任务再套一层主线程调度,导致异步任务的调度链变长——例如await Task.megaYield()的等待逻辑会在两层调度间产生额外的调度延迟。全局重写后,测试本身就在主线程执行,调度路径更短,无额外嵌套包装开销。提升串行队列执行的连续性
每个测试单独调用withMainSerialExecutor时,每个测试代码块都是主线程队列中的独立任务,队列处理完一个测试后可能出现短暂的调度间隙。全局重写后,测试类的所有测试在同一主线程上下文连续执行,减少了队列任务切换的间隙耗时。
其他缩短异步测试耗时的方案
替换通用等待为精准监听
Task.yield()/Task.megaYield()会等待调度器处理所有待执行任务,包含大量无关系统任务,浪费时间。建议改用XCTestExpectation监听特定异步事件,或依赖ViewModel暴露的AsyncSequence/Task直接等待目标操作完成,只等待必要的时间。Mock所有真实异步依赖
将网络请求、数据库操作等真实IO依赖全部Mock,直接返回预设结果,彻底消除真实异步操作的耗时。例如用MockURLSession替代URLSession,给ViewModel注入Mock数据源。开启测试并行执行
在Xcode测试计划中启用「Parallelize Test Execution」,让无状态的测试类同时执行。需确保测试之间无共享状态,避免并发冲突。移除冗余的异步等待逻辑
检查测试代码,删除多余的await或过长的等待时间(如XCTWaiter的超时设置)。例如部分同步操作被误当作异步处理,或等待逻辑重复执行。优化测试初始化逻辑
将重复的SUT初始化(如makeVM())移至setUp()/setUpWithError()中,避免每个测试重复执行初始化;若初始化是异步操作,提前在setUpWithError()中完成,减少测试内的重复耗时。
内容的提问来源于stack exchange,提问作者Evgeniy Kleban

