# combine-schedulers: Making Combine Pipelines Testable Without Waiting on the Clock

> Combine Schedulers is an MIT licensed Swift package that adds type-erasing and controllable scheduler wrappers to Apple's Combine, including an immediate scheduler for synchronous tests and a virtual TestScheduler that advances time by hand. It suits Swift teams whose reactive tests are slow or flaky because of real time delays.

**pointfreeco/combine-schedulers** — ⏰ A few schedulers that make working with Combine more testable and more versatile.

- Repository: https://github.com/pointfreeco/combine-schedulers
- Website: https://www.pointfree.co
- Stars: 795 · Forks: 107
- Language: Swift
- License: MIT
- Published: 2026-10-20 · Updated: 2026-10-20 · Language: en
- Canonical page: https://hysenlabs.com/projects/pointfreeco-combine-schedulers

## The problem is that schedulers make publishers asynchronous

Combine provides the Scheduler protocol, which the documentation calls a powerful abstraction for describing how and when units of work are executed, and which unifies disparate ways of executing work such as DispatchQueue, RunLoop and OperationQueue. The problem follows immediately. The moment you use any of these schedulers in reactive code, you make the publisher asynchronous, and an asynchronous publisher is much harder to test, forcing you to use expectations and to wait for time to pass as your publisher executes. That is the shape of the problem this library addresses, and it is worth naming precisely because the fix is not abstract. A test that waits 100 milliseconds for a queue to drain is a test whose runtime is set by the slowest path in your app rather than by the code under test, and it is a test that fails intermittently when the machine is loaded.

## AnyScheduler erases a scheduler type without a generic

AnyScheduler is a type-erasing wrapper for the Scheduler protocol. The readme explains the gap it fills: Combine ships many type-erasing wrappers, such as AnySubscriber, AnyPublisher and AnyCancellable, yet for some reason does not ship with AnyScheduler. The use case is a view model that performs an API request when a method is called, where the request delivers its output on a background thread and therefore needs a hop to the main queue before the result is assigned.

```swift
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)
  }
}
```

The point of the type erasure is what it avoids. A generic version of the view model is described as heavyweight, because it loudly announces to the outside world that the type uses a scheduler, and worse, it infects any code that touches the view model and also wants to be testable.

## Erasing the type at the call site

Once the view model holds an AnySchedulerOf, the concrete scheduler is chosen at the point of construction, and erased to fit.

```swift
let viewModel = EpisodeViewModel(
  apiClient: ...,
  scheduler: DispatchQueue.main.eraseToAnyScheduler()
)
```

For the common schedulers, DispatchQueue, OperationQueue and RunLoop, there is a static helper on AnyScheduler that shortens this further.

```swift
let viewModel = EpisodeViewModel(
  apiClient: ...,
  scheduler: .main
)
```

And in a test the same call site passes an immediate scheduler instead.

```swift
let viewModel = EpisodeViewModel(
  apiClient: ...,
  scheduler: .immediate
)
```

The result is that production and test differ only in the value passed at the initialiser, and no generic parameter leaks into the view model or into the views that use it. The cost is that the scheduler is chosen by whoever constructs the type, so a view model used from several places has to be handed a compatible one each time, and the type is still not visible in the signature of anything that only stores it.

## The immediate scheduler and the testability it buys

The immediate scheduler is the mechanism that makes the difference in a test. The readme describes using an immediate scheduler instead of DispatchQueue.main so that fetchEpisode delivers its output as soon as possible with no thread hops. The motivation section puts it more broadly: the library provides new schedulers that allow you to turn any asynchronous publisher into a synchronous one for ease of testing and debugging. In practice that means the assertion runs immediately after the trigger call, with no expectation, no wait and no timer. A test that was previously `waitForExpectations(timeout: 1.0)` with a 100 millisecond wait becomes a straight-line sequence of calls and assertions, which is both faster and deterministic. The trade-off is that an immediate scheduler does not reproduce the real ordering guarantees of a queue, so a test that depends on interleaving between two schedulers is not a good candidate for it.

## A TestScheduler that advances time by hand

Beyond the immediate scheduler, the package offers a TestScheduler and an UnimplementedScheduler, plus a set of animated schedulers and a UIScheduler, and there is a section on the concurrency APIs including Publishers.Timer. The TestScheduler is the one that handles time-dependent code properly, and the animated schedulers exist for driving SwiftUI animations deterministically rather than for testing business logic. The UnimplementedScheduler has a different job again: it is for code paths that should never run in a given test, where scheduling anything is a bug rather than something to control. The library also documents how its schedulers interact with the newer Swift concurrency APIs, and the entry point to that discussion is a dedicated section rather than a footnote, which tells you how much of the package exists to bridge Combine and structured concurrency.

## The package layout and the platform matrix it is tested on

The repository is a Swift package with a Sources and a Tests directory, a Package.swift and two version-specific manifests, Package@swift-6.0.swift and Package@swift-6.1.swift, which is how Swift Package Manager lets a package declare different dependencies per toolchain version. The Makefile shows the test matrix in full, and it is broad. The swift test command runs the package tests, and then xcodebuild runs the same scheme against an iOS Simulator destination on an iPhone 17 Pro, against macOS, and against a tvOS Simulator, and finally only builds rather than tests for a watchOS simulator on an Apple Watch Series 11.

```bash
swift test
```

Running the suite on the Apple platforms rather than only under swift test is the detail that matters for a Combine package, because a scheduler that behaves correctly on macOS can still misbehave on a platform with different run loop semantics.

## Formatting, library evolution and how releases happen

The build tooling is small and current. Formatting uses the swift format command applied in place across the package manifest and the source and test directories.

```bash
swift format --in-place --recursive ./Package.swift ./Sources ./Tests
```

There is a separate target for building with library evolution enabled, which is what a package needs if it is going to be consumed by a library that itself ships with evolution, since it emits the module interface that makes that possible.

```bash
	swift build \
		-c release \
		--target CombineSchedulers \
		-Xswiftc -emit-module-interface \
		-Xswiftc -enable-library-evolution
```

The release history shows steady work rather than bursts: 1.2.0 on 2026-04-16, 1.2.1 on 2026-08-27 and 1.2.2 on 2026-09-01, with the last push on 2026-10-07. The licence is MIT, and the project is from Point-Free, the same team behind the Combine video series the library was designed across.

## Where this sits against the alternatives

The alternative approaches come in three shapes. The first is to keep using real schedulers and write tests with expectations, accepting the waiting and the flakiness. The second is to inject a generic scheduler, which gives full type safety but spreads generics through everything that touches the type under test, which is exactly the infection the library set out to avoid. The third is to reimplement the pieces you need, which is what you end up doing if you want a virtual clock and do not want a dependency. This library sits between the second and the third, and its value is concentrated in the type-erasing wrapper and the immediate scheduler, which together remove both the generic spread and the real-time wait without asking you to build a scheduler yourself. The cost is a dependency and, for some of the type-erased usage, the loss of compile-time checking on which scheduler is in use.

## Conclusion

Combine Schedulers fits a Swift team whose Combine tests wait on real time and flake as a result, and that wants to inject a scheduler without threading a generic parameter through every type that touches the view model. It is the wrong choice if your code has no Combine pipelines, and the wrong choice if you need SwiftUI animation control, since the animated schedulers exist for that purpose rather than for testing. Before you convert a suite, measure how much of its runtime is spent waiting, because that number is the case for changing anything.

## FAQ

### Why do Combine schedulers make tests slow and flaky?

The moment a scheduler such as DispatchQueue, RunLoop or OperationQueue is used in a reactive pipeline, the publisher becomes asynchronous, so tests must use expectations and wait for real time to pass. That adds runtime set by the slowest path in your app and causes intermittent failures under load.

### What does AnyScheduler add that Combine does not ship?

Combine ships many type-erasing wrappers, such as AnySubscriber, AnyPublisher and AnyCancellable, but no AnyScheduler. It lets a view model hold a scheduler that can be swapped from the outside without introducing a generic parameter, which would otherwise infect every type that touches it.

### How do I use an immediate scheduler in a test?

Hold an AnySchedulerOf in the type under test and construct it with an immediate scheduler in the test, so the publisher delivers its output as soon as possible with no thread hops and the assertion runs without an expectation or a timeout.

### How is combine-schedulers tested across Apple platforms?

The Makefile runs swift test and then xcodebuild for the same scheme against iOS Simulator, macOS and tvOS Simulator destinations, and builds for watchOS. The package also has version-specific manifests for Swift 6.0 and 6.1.

### What are the animated schedulers and the UnimplementedScheduler for?

The animated schedulers drive SwiftUI animations deterministically, while the UnimplementedScheduler is for code paths that should never execute in a given test, so that scheduling work there signals a bug rather than something to control.

## Sources

- [License: MIT](https://github.com/pointfreeco/combine-schedulers/blob/main/LICENSE)
- [pointfreeco/combine-schedulers on GitHub](https://github.com/pointfreeco/combine-schedulers)
- [Project website](https://www.pointfree.co)
- [README](https://github.com/pointfreeco/combine-schedulers/blob/main/README.md)
- [Releases](https://github.com/pointfreeco/combine-schedulers/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/pointfreeco-combine-schedulers
