Combine Schedulers

repository·main·Indexed 21 days ago

https://github.com/pointfreeco/combine-schedulers

A collection of Combine schedulers designed to improve testability and versatility in reactive programming. It provides specialized implementations of the Combine Scheduler protocol, including AnyScheduler for type erasure, TestScheduler for deterministic time control, ImmediateScheduler for synchronous testing, and UIScheduler for optimized main-thread execution. The library also includes async/await compatible APIs and tools to animate Combine streams in SwiftUI and UIKit.

Tokens
2.8K
Snippets
7
Records
12
Agent score
24%

What's inside combine-schedulers

  1. Overview of Combine Schedulers

    main
    Combine Schedulers is a library providing specialized implementations of the Combine Scheduler protocol. Its primary purpose is to make working with Combine more testable and versatile by allowing developers to control how and when units of work are executed. Specifically, it enables turning asynchronous publishers into synchronous ones for easier testing and debugging, avoiding the need for complex expectations or waiting for real time to pass.
  2. Understand the motivation for using Combine Schedulers

    main
    While the standard Combine framework provides Scheduler implementations like DispatchQueue, RunLoop, and OperationQueue, using them directly in reactive code makes publishers inherently asynchronous. This makes testing difficult because it requires using expectations and waiting for time to pass. This library provides alternative schedulers that allow you to control execution timing, making asynchronous work behave synchronously during tests.
  3. Test time-dependent publishers with `TestScheduler`

    main

    A TestScheduler allows for deterministic control over time and execution. It is essential for testing Combine operators that depend on time, such as debounce, throttle, delay, timeout, and receive(on:).

    You can use scheduler.advance(by:) to manually move time forward, allowing you to assert the state of your publishers at specific points in time without waiting for real-world clock time.

    let scheduler = DispatchQueue.test
    
    let first = Future<Int, Never> { callback in
      scheduler.schedule(after: scheduler.now.advanced(by: 1)) { callback(.success(1)) }
    }
    let second = Future<Int, Never> { callback in
      scheduler.schedule(after: scheduler.now.advanced(by: 2)) { callback(.success(2)) }
    }
    
    var output: [Int] = []
    let cancellable = race(first, second).sink { output.append($0) }
    
    scheduler.advance(by: 1)
    XCTAssertEqual(output, [1])
    
    scheduler.advance(by: 1)
    XCTAssertEqual(output, [1])
  4. Use `ImmediateScheduler` for synchronous testing

    main

    The ImmediateScheduler executes work immediately, effectively collapsing time into a single point. Unlike TestScheduler, you cannot explicitly control the flow of time, but it is highly useful for testing code that uses receive(on:) or subscribe(on:) without needing to use XCTestExpectation or wait for real-world delays.

    It is available for common types: DispatchQueue.immediate, RunLoop.immediate, and OperationQueue.immediate.

    // In your view model, inject the scheduler
    class HomeViewModel: ObservableObject {
      let scheduler: AnySchedulerOf<DispatchQueue>
      // ...
      func reloadButtonTapped() {
        Just(())
          .delay(for: .seconds(10), scheduler: self.scheduler)
          .flatMap { apiClient.fetchEpisodes() }
          .assign(to: &self.$episodes)
      }
    }
    
    // In your test, use .immediate to skip the 10s delay
    func testViewModel() {
      let viewModel = HomeViewModel(
        apiClient: .mock,
        scheduler: .immediate
      )
      viewModel.reloadButtonTapped()
      XCTAssertEqual(viewModel.episodes, [Episode(id: 42)])
    }
  5. Use `AnyScheduler` to avoid generic pollution

    main

    The AnyScheduler provides a type-erasing wrapper for the Scheduler protocol. This allows you to inject different types of schedulers (like DispatchQueue.main for production and ImmediateScheduler for testing) into your classes without making those classes generic.

    Instead of using a generic type like class MyViewModel<S: Scheduler>, use AnySchedulerOf<T> where T is the underlying scheduler type (e.g., DispatchQueue).

    To create an AnyScheduler from a live scheduler, use .eraseToAnyScheduler(). For common schedulers like DispatchQueue, OperationQueue, and RunLoop, you can use convenient static helpers like .main or .immediate.

    class EpisodeViewModel: ObservableObject {
      @Published var episode: Episode?
      let apiClient: ApiClient
      let scheduler: AnySchedulerOf<DispatchQueue>
    
      init(apiClient: ApiClient, scheduler: AnySchedulerOf<DispatchQueue>) {
        self.apiClient = apiClient
        self.scheduler = scheduler
      }
    
      func reloadButtonTapped() {
        self.apiClient.fetchEpisode()
          .receive(on: self.scheduler)
          .assign(to: &self.$episode)
      }
    }
    
    // Production usage
    let viewModel = EpisodeViewModel(
      apiClient: ..., 
      scheduler: .main
    )
    
    // Test usage
    let viewModel = EpisodeViewModel(
      apiClient: ..., 
      scheduler: .immediate
    )
  6. Enforce no-scheduler requirements with `UnimplementedScheduler`

    main

    The UnimplementedScheduler is a specialized scheduler used in testing to ensure that a specific code path does not rely on any asynchronous scheduling.

    If the code being tested attempts to use the provided .unimplemented scheduler, the test will fail. This is useful for documenting and verifying that certain features (like simple state toggles) remain synchronous and simple.

    func testFavoriteButton() {
      let viewModel = EpisodeViewModel(
        apiClient: .mock,
        mainQueue: .unimplemented
      )
      viewModel.episode = .mock
    
      viewModel.favoriteButtonTapped()
      XCTAssertEqual(viewModel.episode?.isFavorite, true)
    }
  7. Execute work immediately on the main thread with `UIScheduler`

    main
    The UIScheduler executes work on the main queue as soon as possible. Unlike DispatchQueue.main.async, which always incurs a thread hop, UIScheduler will perform the work immediately if it is already being called from the main thread. This is ideal for high-performance UI updates or animations where a thread hop would be problematic.
  8. Install CombineSchedulers via Swift Package Manager

    main

    To add CombineSchedulers to your Xcode project, add it as a package dependency:

    1. From the File menu, select Swift Packages › Add Package Dependency…
    2. Enter https://github.com/pointfreeco/combine-schedulers into the package repository URL text field.

    Target Configuration:

    • Single application target: Add CombineSchedulers directly to your application.
    • Multiple targets: Create a shared framework that depends on CombineSchedulers, then depend on that shared framework from your other targets.
  9. Use async-friendly Concurrency APIs

    main

    The library provides async/await compatible methods for interacting with schedulers:

    • scheduler.sleep(for:): Suspends the current task for a specified duration.
    • scheduler.timer(interval:): An AsyncSequence that yields values at a regular interval.
    // Suspend the current task for 1 second
    try await scheduler.sleep(for: .seconds(1))
    
    // Perform work every 1 second
    for await instant in scheduler.timer(interval: .seconds(1)) {
      // ...
    }
  10. Animate Combine streams in SwiftUI or UIKit

    main

    The library provides helpers to transform a scheduler into an animated one, mirroring SwiftUI and UIKit animation APIs.

    • SwiftUI: Use .animation() or .transaction() on a scheduler to wrap actions in an animation or transaction.
    • UIKit: Use .animate(withDuration:) on a scheduler to mirror UIView.animate behavior.
    // SwiftUI style
    self.apiClient.fetchEpisode()
      .receive(on: self.scheduler.animation())
      .assign(to: &self.$episode)
    
    // UIKit style
    self.apiClient.fetchEpisode()
      .receive(on: self.scheduler.animate(withDuration: 0.3))
      .assign(to: &self.$episode)
  11. Create testable timers with `Publishers.Timer`

    main

    Instead of using Foundation's Timer.publisher, use Publishers.Timer or the .timerPublisher(every:) method on a scheduler. This allows you to use any scheduler (including TestScheduler) for the timer, making time-based Combine code fully testable and deterministic.

    • Publishers.Timer(every:scheduler:): Creates a timer publisher on a specific scheduler.
    • scheduler.timerPublisher(every:): A convenience method on the scheduler to derive a timer.
    // Using the publisher directly
    Publishers.Timer(every: .seconds(1), scheduler: DispatchQueue.main)
      .autoconnect()
      .sink { print("Timer", $0) }
    
    // Using the scheduler helper
    DispatchQueue.main.timerPublisher(every: .seconds(1))
      .autoconnect()
      .sink { print("Timer", $0) }
    
    // Testing a timer with TestScheduler
    let scheduler = DispatchQueue.test
    var output: [Int] = []
    
    Publishers.Timer(every: 1, scheduler: scheduler)
      .autoconnect()
      .sink { _ in output.append(output.count) }
      .store(in: &self.cancellables)
    
    scheduler.advance(by: 1)
    XCTAssertEqual(output, [0])
    
    scheduler.advance(by: 1_000)
    XCTAssertEqual(output, Array(0...1_001))
  12. Check CombineSchedulers compatibility

    main

    CombineSchedulers is compatible with iOS 13.2 and higher.

    Warning: Avoid using iOS 13.1 or lower. There are known bugs in the Combine framework on those versions that cause crashes when comparing DispatchQueue.SchedulerTimeType values, which is required for TestScheduler to function.