Swift Concurrency Extras

repository·main·Indexed 19 days ago

https://github.com/pointfreeco/swift-concurrency-extras

A suite of tools to make Swift concurrency more reliable and easier to test. It provides utilities for deterministic async testing via withMainSerialExecutor, thread-safe state management with LockIsolated, type-erased sendability with AnyHashableSendable and UncheckedSendable, and helpers for AsyncStream, AsyncThrowingStream, and Task.

Tokens
3.7K
Snippets
10
Records
19
Agent score
66%

What's inside swift-concurrency-extras

  1. Use TaskLocalTrait for testing task-local values

    main

    The ConcurrencyExtrasTestSupport library provides a TaskLocalTrait to make testing code that relies on task-local values easier and more predictable. This trait allows you to inject specific values into a task's local storage for the duration of a test, ensuring that the code under test can access the expected environment without manual setup of TaskLocal.set in every test case.

    // Example usage pattern (conceptual based on documentation structure)
    // Use the taskLocal trait to provide a value for a specific TaskLocal key
    // within a test environment.
  2. Work with AsyncStream and AsyncThrowingStream helpers

    main

    The library provides several helper APIs to make working with and testing Swift streams easier:

    • Back-ported makeStream(of:): Provides the Swift 5.9 makeStream(of:) functionality to older Swift versions. This is useful for creating controlled streams in tests to simulate dependency behavior.
    • AsyncStream.never and AsyncThrowingStream.never: Static properties representing streams that live forever and never emit any values. Use these to override dependencies that should suspend indefinitely.
    • AsyncStream.finished and AsyncThrowingStream.finished(throwing:): Static properties representing streams that complete immediately without emitting any values. Use these to simulate dependencies that finish or fail immediately.
    // Example: Using makeStream to simulate dependency behavior
    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)
    
    // Example: Using .never to simulate an infinite, non-emitting stream
    let model = FeatureModel(screenshots: { .never })
    
    // Example: Using .finished to simulate immediate completion
    let model = FeatureModel(screenshots: { .finished })
  3. Use enhanced `Task` functionality

    main

    The library extends the standard Task type with several utilities:

    • Task.never(): An asynchronous function that suspends forever. It returns a value of any type, making it ideal for satisfying dependency requirements in tests where you want the calling code to suspend indefinitely without returning actual data.
    • Task.cancellableValue: A property that awaits the unstructured task's value while ensuring that cancellation from the current async context is correctly propagated.
    • Task.megaYield(): A utility that suspends the current task multiple times. This is a 'blunt tool' intended to reduce flakiness in async tests by giving other tasks more time to execute. Note: It is preferred to use serial execution instead of megaYield() where possible.
    // Example: Using Task.never() to satisfy a dependency requirement
    struct SettingsClient {
      var fetchSettings: () async throws -> Settings
    }
    
    let client = SettingsClient(
      fetchSettings: { try await Task.never() }
    )
  4. Use `AnyHashableSendable` for type-erased sendable values

    main
    AnyHashableSendable is a type-erased wrapper similar to AnyHashable, but it preserves the Sendable conformance of the underlying value. This is useful when you need to store heterogeneous values in a collection while maintaining Swift concurrency safety.
  5. Run asynchronous tests deterministically with withMainSerialExecutor

    main

    To prevent flaky tests caused by non-deterministic task scheduling, use withMainSerialExecutor(operation:). This function runs all tasks spawned within the operation serially on the main thread. While this differs from production behavior, it ensures that Task.yield() calls allow tasks to reach suspension points predictably, making async unit tests 100% deterministic.

    // Wrap a specific block of code
    func testBasics() async {
      await withMainSerialExecutor {
        // Your test logic here
      }
    }
    
    // Wrap an entire XCTestCase by overriding invokeTest
    final class FeatureModelTests: XCTestCase {
      override func invokeTest() {
        withMainSerialExecutor {
          super.invokeTest()
        }
      }
    }
  6. Strategies for reliably testing async code

    main

    When testing asynchronous code, you can choose between deterministic testing using a serial executor or non-deterministic testing using the default global executor.

    Deterministic Testing with withMainSerialExecutor

    For most day-to-day tests (e.g., asserting that a user action triggers an async unit of work that changes state), it is recommended to use withMainSerialExecutor(operation:).

    Pros:

    • Makes tests 100% deterministic.
    • Allows for strong assertions on state changes.
    • Simplifies testing of logic that can be described as a discrete set of inputs and outputs.

    Cons:

    • Technically alters the runtime behavior compared to production, as it forces serial execution on the main executor.

    Testing Complex Concurrency

    If your code involves highly complex, truly concurrent operations that rely on the specific timing or interleaving of tasks, consider a two-tiered testing approach:

    1. Core Logic Tests: Use withMainSerialExecutor(operation:) to deterministically assert how the core system behaves.
    2. Concurrency Integration Tests: Use the default global executor to test actual concurrent behavior. Note that these tests may require weaker assertions due to inherent non-determinism.
  7. Reliably testing async code with `withMainSerialExecutor`

    main

    Testing asynchronous Swift code using standard async test methods or unstructured Task blocks can lead to non-deterministic (flaky) tests. This happens because the Swift runtime's global concurrent executor schedules work in ways that are unpredictable, making it difficult to assert on intermediate states (like a loading boolean) between suspension points.

    To solve this, use withMainSerialExecutor. This tool temporarily alters how Swift enqueues asynchronous work, serializing it to the main thread. This ensures that when you call await Task.yield(), all currently suspended work is guaranteed to execute before the test continues, making your tests 100% deterministic.

    func testGetFact() async {
      await withMainSerialExecutor {
        let model = FeatureModel(numberFact: { number in
          await Task.yield()
          return "\(number) is a good number!"
        })
        
        let task = Task { await model.getFactButtonTapped() }
        await Task.yield()
        
        XCTAssertEqual(model.isLoadingFact, true)  // ✅ Guaranteed to pass
        await task.value
        XCAssertEqual(model.fact, "0 is a good number!")  // ✅
        XCTAssertEqual(model.isLoadingFact, false)  // ✅
      }
    }
  8. Force all tests to run on the main serial executor

    main

    If you want to avoid wrapping every individual test method in a withMainSerialExecutor block, you can override the invokeTest() method in your XCTestCase subclass. This forces every test within that class to run under the main serial executor by default.

    override func invokeTest() {
      withMainSerialExecutor {
        super.invokeTest()
      }
    }
  9. Run tests with `withMainSerialExecutor` for deterministic execution

    main

    Some asynchronous tests are difficult to test because of how the Swift runtime processes suspension points. withMainSerialExecutor attempts to run all tasks spawned within the provided operation serially and deterministically. This makes asynchronous tests faster and significantly more reliable.

    Warning: This API is intended only for use in tests. Do not use it in application code. It relies on a global, mutable variable in the Swift runtime and provides no scoping guarantees if that variable changes during the operation.

    When using this in tests, you may need to insert a Task.yield() in your dependency endpoints to prevent the compiler from inlining async closures that don't perform actual async work.

    func testIsLoading() async {
      await withMainSerialExecutor {
        let model = NumberFactModel(getFact: {
          await Task.yield() // Required to prevent inlining
          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.")
      }
    }