可靠地测试 Swift 并发。
此库旨在支持为 Point-Free 制作的库和剧集,Point-Free 是一个由 Brandon Williams 和 Stephen Celis 主持的探索 Swift 编程语言的视频系列。
您可以在这里观看所有剧集。
此库附带了许多工具,可以更轻松、更可测试地使用 Swift 并发。
LockIsolated
类型有助于将其他值包装在隔离的上下文中。它将值包装在一个带有锁的类中,这允许您使用同步接口读取和写入该值。
AnyHashableSendable
类型是一个类型擦除的包装器,类似于 AnyHashable
,但保留了底层值的可发送性。
该库附带了许多辅助 API,分布在两种 Swift 流类型中
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() // Simulate a screenshot being taken.
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
}
您可以在测试中覆盖客户端的 fetchSettings
端点,方法是等待 Task.never()
以永远暂停
SettingsClient(
fetchSettings: { try await Task.never() }
)
Task.cancellableValue
是一个属性,它等待非结构化任务的 value
属性,同时传播来自当前异步上下文的取消。
Task.megaYield()
是一个粗暴的工具,可以通过多次暂停当前任务并提高其他异步工作有足够时间开始的几率,从而使不稳定的异步测试稍微稳定一些。在可能的情况下,首选 串行执行 的可靠性。
由于运行时处理挂起点的方式,一些异步代码在 Swift 中 出了名的难以 测试。该库附带了一个静态函数 withMainSerialExecutor
,它尝试串行且确定性地运行在操作中生成的所有任务。此函数可用于使异步测试更快且更少不稳定。
警告:此 API 仅旨在从测试中使用,以使其更可靠。请不要从应用程序代码中使用它。
我们说它“尝试 串行且确定性地运行在操作中生成的所有任务”,因为在底层,它依赖于 Swift 运行时中的全局可变变量来完成其工作,并且如果此可变变量在操作期间发生更改,则没有作用域保证。
例如,考虑以下看似简单的模型,它发出网络请求并在请求进行中时管理一些 isLoading
状态
@Observable
class NumberFactModel {
var fact: String?
var isLoading = false
var number = 0
// Inject the request dependency explicitly to make it testable, but can also
// be provided via a dependency management library.
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: Handle error
}
}
}
我们很希望能够编写一个测试,允许我们确认 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.")
}
但这仍然在绝大多数情况下失败。
通过在主串行执行器上运行整个测试,可以解决这些问题以及更多问题。由于 Swift 能够内联实际上不执行异步工作的异步闭包,因此您还必须在 getFact
端点中插入一个小的 yield
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% 的时间。
此库的最新文档可在此处获得:here。
感谢 Pat Brown 和 Thomas Grapperon 在库发布之前提供反馈。特别感谢 Kabir Oberai,他帮助我们解决了 Xcode 错误并随库一起发布了串行执行工具。
Concurrency Extras 只是使在 Swift 中编写可测试代码更容易的众多库之一。
Case Paths:用于处理和测试枚举的工具。
Clocks:一些时钟,使使用 Swift 并发更可测试和更通用。
Combine Schedulers:一些调度器,使使用 Combine 更可测试和更通用。
Composable Architecture:一个用于以一致且易于理解的方式构建应用程序的库,考虑了组合、测试和人体工程学。
Custom Dump:用于调试、比较和测试应用程序数据结构的工具集合。
Dependencies:一个受 SwiftUI “环境” 启发的依赖项管理库。
Snapshot Testing:通过记录工件并针对工件进行断言来断言您的应用程序。
XCTest Dynamic Overlay:从应用程序代码调用 XCTFail
和其他通常仅用于测试的辅助方法。
此库在 MIT 许可证下发布。有关详细信息,请参阅 LICENSE。