swift-concurrency-extras
可靠且可测试的Swift并发。
了解更多
这个库旨在支持为Point-Free制作的库和剧集,Point-Free是一个由Brandon Williams和Stephen Celis主持的探索Swift编程语言的视频系列。
你可以在这里观看所有剧集。
动机
这个库提供了许多工具,使Swift并发变得更容易使用和更易于测试。
LockIsolated
LockIsolated
类型帮助将其他值包装在隔离的上下文中。它使用锁将值包装在一个类中,允许你通过同步接口读写该值。
流
该库为两种Swift流类型提供了多个辅助API:
-
有一些辅助函数可以将任何
AsyncSequence
符合性擦除为任一具体流类型。这允许你将流类型视为某种"类型擦除"的AsyncSequence
。例如,假设你有这样一个依赖客户端:
struct ScreenshotsClient { var screenshots: () -> AsyncStream<Void> }
然后你可以构建一个将
NotificationCenter.Notifications
异步序列"擦除"为流的活跃实现:extension ScreenshotsClient { static let live = Self( screenshots: { NotificationCenter.default .notifications(named: UIApplication.userDidTakeScreenshotNotification) .map { _ in } .eraseToStream() // ⬅️ } ) }
使用
eraseToThrowingStream()
来传播来自抛出异常的异步序列的失败。 -
Swift 5.9的
makeStream(of:)
函数已经被向后移植。它在需要覆盖返回流的依赖端点的测试中很有用:let screenshots = AsyncStream.makeStream(of: Void.self) let model = FeatureModel(screenshots: { screenshots.stream }) XCTAssertEqual(model.screenshotCount, 0) screenshots.continuation.yield() // 模拟截图 XCTAssertEqual(model.screenshotCount, 1)
-
提供了静态的
AsyncStream.never
和AsyncThrowingStream.never
辅助函数,它们表示永远存在且从不发出的流。它们在需要用永远挂起且不发出的流覆盖依赖端点的测试中很有用。let model = FeatureModel(screenshots: { .never })
-
提供了静态的
AsyncStream.finished
和AsyncThrowingStream.finished(throwing:)
辅助函数,它们表示立即完成且不发出的流。它们在需要用立即完成/失败的流覆盖依赖端点的测试中很有用。
任务
该库为Task
类型增加了新功能。
-
静态函数
Task.never()
可以异步返回任何类型的值,但通过永久挂起来实现。这对于以不需要实际从该端点返回数据的方式满足依赖要求很有用。例如,假设你有这样一个依赖客户端:
struct SettingsClient { var fetchSettings: () async throws -> Settings }
你可以在测试中通过等待
Task.never()
来覆盖客户端的fetchSettings
端点,使其永久挂起:SettingsClient( fetchSettings: { try await Task.never() } )
-
Task.cancellableValue
是一个属性,它等待非结构化任务的value
属性,同时从当前异步上下文传播取消。 -
Task.megaYield()
是一个粗暴的工具,可以通过多次挂起当前任务来使不稳定的异步测试变得稳定一些,增加其他异步工作有足够时间启动的机会。在可能的情况下,优先考虑使用串行执行的可靠性。
串行执行
由于运行时处理挂起点的方式,Swift中的一些异步代码notoriously难以测试。该库提供了一个静态函数withMainSerialExecutor
,它试图串行和确定性地运行操作中产生的所有任务。这个函数可以用来使异步测试更快、更稳定。
警告:这个API仅用于测试,以使测试更可靠。请不要在应用程序代码中使用它。
我们说它"_尝试_串行和确定性地运行操作中产生的所有任务",因为它在底层依赖Swift运行时中的一个全局可变变量来完成工作,如果这个可变变量在操作期间发生变化,就无法保证其作用域。
例如,考虑以下看似简单的模型,它发起网络请求并在请求进行时管理isLoading
状态:
@Observable
class NumberFactModel {
var fact: String?
var isLoading = false
var number = 0
// 显式注入请求依赖以使其可测试,也可以通过依赖管理库提供
let getFact: (Int) async throws -> String
func getFactButtonTapped() async {
self.isLoading = true
defer { self.isLoading = false }
do {
self.fact = try await self.getFact(self.number)
} catch {
// TODO: 处理错误
}
}
}
我们希望能够编写一个测试来确认isLoading
状态先变为true
然后变为false
。你可能希望它像这样简单:
func testIsLoading() async {
let model = NumberFactModel(getFact: {
"\($0) is a good number."
})
let task = Task { await model.getFactButtonTapped() }
XCTAssertEqual(model.isLoading, true)
XCTAssertEqual(model.fact, nil)
await task.value
XCTAssertEqual(model.isLoading, false)
XCTAssertEqual(model.fact, "0 is a good number.")
}
然而,这几乎100%会失败。问题在于,创建非结构化Task
后的下一行会在非结构化任务内部的行之前执行,所以我们永远不会检测到isLoading
状态变为true
的时刻。
你可能希望通过使用Task.yield
来在getFactButtonTapped
方法被调用和请求完成之间插入一些时间:
func testIsLoading() async {
let model = NumberFactModel(getFact: {
"\($0) is a good number."
})
let task = Task { await model.getFactButtonTapped() }
+ await Task.yield()
XCTAssertEqual(model.isLoading, true)
XCTAssertEqual(model.fact, nil)
await task.value
XCTAssertEqual(model.isLoading, false)
XCTAssertEqual(model.fact, "0 is a good number.")
}
但这仍然在绝大多数情况下失败。
这些问题以及更多问题可以通过在主串行执行器上运行整个测试来解决。你还需要在getFact
端点中插入一个小的yield,因为Swift能够内联不实际执行异步工作的异步闭包:
func testIsLoading() async {
+ await withMainSerialExecutor {
let model = NumberFactModel(getFact: {
+ await Task.yield()
return "\($0) is a good number."
})
let task = Task { await model.getFactButtonTapped() }
await Task.yield()
XCTAssertEqual(model.isLoading, true)
XCTAssertEqual(model.fact, nil)
await task.value
XCTAssertEqual(model.isLoading, false)
XCTAssertEqual(model.fact, "0 is a good number.")
+ }
}
这个小小的改变使得这个测试100%确定性地通过。
文档
该库的最新文档可在此处获取。
致谢
感谢Pat Brown和Thomas Grapperon在发布前对该库提供反馈。特别感谢Kabir Oberai帮助我们解决了Xcode的一个bug,并使我们能够在库中提供串行执行工具。
其他库
Concurrency Extras只是让Swift中编写可测试代码变得更容易的众多库之一。
-
Case Paths:用于处理和测试枚举的工具。
-
Clocks:一些时钟,使Swift并发更易于测试和更加多功能。
-
Combine Schedulers:一些调度器,使Combine更易于测试和更加多功能。
-
Composable Architecture:一个库,用于以一致且可理解的方式构建应用程序,考虑到了组合、测试和人体工程学。
-
Custom Dump:用于调试、比较和测试应用程序数据结构的工具集合。
-
Dependencies:受SwiftUI的"环境"启发的依赖管理库。
-
Snapshot Testing:通过记录和对比制品来断言你的应用程序。
-
XCTest Dynamic Overlay:从应用程序代码调用
XCTFail
和其他通常仅用于测试的辅助函数。
许可证
该库根据MIT许可证发布。详情请参见LICENSE。