swift-concurrency-extras

CI Slack

Reliably testable Swift concurrency.

Learn more

This library was designed to support libraries and episodes produced for Point-Free, a video series exploring the Swift programming language hosted by Brandon Williams and Stephen Celis.

You can watch all of the episodes here.

video poster image

Motivation

This library comes with a number of tools that make working with Swift concurrency easier and more testable.

ActorIsolated and LockIsolated

The ActorIsolated and LockIsolated types help wrap other values in an isolated context. ActorIsolated wraps the value in an actor so that the only way to access and mutate the value is through an async/await interface. LockIsolated wraps the value in a class with a lock, which allows you to read and write the value with a synchronous interface. You should prefer to use ActorIsolated when you have access to an asynchronous context.

Streams

The library comes with numerous helper APIs spread across the two Swift stream types:

Tasks

The library enhances the Task type with new functionality.

UncheckedSendable

A wrapper type that can make any type Sendable, but in an unsafe and unchecked way. This type should only be used as an alternative to @preconcurrency import, which turns off concurrency checks for everything in the library. Whereas UncheckedSendable allows you to turn off concurrency warnings for just one single usage of a particular type.

While SE-0302 mentions future work of “Adaptor Types for Legacy Codebases”, including an UnsafeTransfer type that serves the same purpose, it has not landed in Swift.

Serial execution

Some asynchronous code is notoriously difficult to test in Swift due to how suspension points are processed by the runtime. The library comes with a static function, withMainSerialExecutor, that runs all tasks spawned in an operation serially and deterministically. This function can be used to make asynchronous tests faster and less flakey.

For example, consider the following seemingly simple model that makes a network request and manages so isLoading state while the request is inflight:

@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
    }
  }
}

We would love to be able to write a test that allows us to confirm that the isLoading state flips to true and then false. You might hope that it is as easy as this:

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.")
}

However this fails almost 100% of the time. The problem is that the line immediately after creating the unstructured Task executes before the line inside the unstructured task, and so we never detect the moment the isLoading state flips to true.

You might hope you can wiggle yourself in between the moment the getFactButtonTapped method is called and the moment the request finishes by using a Task.yield:

 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.")
 }

But that still fails the vast majority of times.

These problems, and more, can be fixed by running this entire test on the main serial executor:

 func testIsLoading() async {
+  withMainSerialExecutor {
     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.")
+  }
 }

That one change makes this test pass deterministically, 100% of the time.

Documentation

The latest documentation for this library is available here.

Credits and thanks

Thanks to Pat Brown and Thomas Grapperon for providing feedback on the library before its release. Special thanks to Kabir Oberai who helped us work around an Xcode bug and ship serial execution tools with the library.

Other libraries

Concurrency Extras is just one library that makes it easier to write testable code in Swift.

License

This library is released under the MIT license. See LICENSE for details.