Concurrency Extras

CI Slack

可靠地测试 Swift 并发。

了解更多

此库旨在支持为 Point-Free 制作的库和剧集,Point-Free 是一个由 Brandon WilliamsStephen Celis 主持的探索 Swift 编程语言的视频系列。

您可以在这里观看所有剧集。

video poster image

动机

此库附带了许多工具,可以更轻松、更可测试地使用 Swift 并发。

LockIsolated

LockIsolated 类型有助于将其他值包装在隔离的上下文中。它将值包装在一个带有锁的类中,这允许您使用同步接口读取和写入该值。

AnyHashableSendable

AnyHashableSendable 类型是一个类型擦除的包装器,类似于 AnyHashable,但保留了底层值的可发送性。

Streams

该库附带了许多辅助 API,分布在两种 Swift 流类型中

Tasks

该库使用新功能增强了 Task 类型。

串行执行

由于运行时处理挂起点的方式,一些异步代码在 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 中编写可测试代码更容易的众多库之一。

许可证

此库在 MIT 许可证下发布。有关详细信息,请参阅 LICENSE