Testing a pipeline
Controlling time so a debounce test does not take three seconds
Combine is more testable than it looks, for one reason: the scheduler is an injected dependency, and a scheduler is a clock. Control the clock and time-based operators stop being slow.
The basic shape
An asynchronous pipeline needs the test to wait, which with Swift Testing is a confirmation:
import Testing
import Combine
@Test func searchDebouncesInput() async throws {
var cancellables: Set<AnyCancellable> = []
let subject = PassthroughSubject<String, Never>()
var received: [String] = []
await confirmation { confirmed in
subject
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.sink { value in
received.append(value)
confirmed()
}
.store(in: &cancellables)
subject.send("s")
subject.send("sw")
subject.send("swift")
try? await Task.sleep(for: .milliseconds(500))
}
#expect(received == ["swift"])
}
That works and it takes half a second, which multiplied across a suite is the reason people stop writing these tests.
Injecting the scheduler
Make the scheduler a parameter and the delay disappears:
final class SearchModel {
private let scheduler: any Scheduler
init(scheduler: some Scheduler = DispatchQueue.main) {
self.scheduler = scheduler
}
}
In production it is DispatchQueue.main; in tests it is ImmediateScheduler.shared, which runs
everything synchronously with no delay at all. A three-second debounce completes instantly, and the
test needs no waiting, no confirmation and no flakiness.
Warning
ImmediateScheduler removes the delay entirely, so it proves the pipeline is wired correctly but
cannot prove the timing is right. A test with it passes whether the debounce is 300ms or 30s.
For behaviour that depends on the interval, you need a scheduler with a controllable clock —
Apple ships none, so this is where a test scheduler from combine-schedulers earns its place, or
you write the twenty lines yourself.
Collecting output
The recurring boilerplate is “run this publisher and give me everything it produced”. Worth writing once:
extension Publisher {
func collectValues(
timeout: TimeInterval = 1
) async throws -> [Output] where Failure == Never {
var values: [Output] = []
for await value in values(timeout: timeout) {
values.append(value)
}
return values
}
}
In practice the simplest version uses .values, the AsyncSequence bridge covered in the next
chapter, which turns a Combine test into an ordinary async one:
@Test func loadsItems() async throws {
let model = ItemsModel(api: StubAPI(items: [.sample]))
var results: [LoadState] = []
for await state in model.state.values.prefix(2).values {
results.append(state)
}
#expect(results.count == 2)
}
prefix(2) is what makes this terminate. An infinite publisher iterated with for await never
finishes, and the test hangs rather than failing — always bound the sequence.
Testing failure paths
The failure chapter argued that where you catch matters. That claim is testable, and it is worth a test because the bug it prevents is invisible:
@Test func searchSurvivesAFailedRequest() async throws {
let api = StubAPI(results: [.failure(APIError.offline), .success([.sample])])
let model = SearchModel(api: api, scheduler: ImmediateScheduler.shared)
model.search("first") // fails
model.search("second") // must still work
#expect(model.results == [.sample])
}
If the catch is outside the inner publisher, the second search returns nothing and this test fails.
That is precisely the production bug that otherwise ships and is reported as “search stops working
sometimes”.
Making the dependencies stubbable
None of this is possible if a model constructs URLSession.shared internally. The pipeline must take
its inputs:
protocol SearchAPI {
func search(_ term: String) -> AnyPublisher<[Result], APIError>
}
struct StubAPI: SearchAPI {
var results: [Swift.Result<[Result], APIError>]
func search(_ term: String) -> AnyPublisher<[Result], APIError> {
// return the next canned response
}
}
AnyPublisher at the protocol boundary is exactly the right use of type erasure — the concrete
chain type is unwritable, and the stub returns a completely different one.
What is worth testing
Not the operators. debounce works; Apple tested it. What is worth testing is the part you wrote:
- The shape of the output — that a search emits
.loadingthen.loaded, in that order. - That failure is contained — the test above, and it is the highest-value one here.
- That cancellation happens — a new search cancels the previous request, verified by asserting the stub saw a cancellation.
- The transformation logic — anything inside a
maporscancomplex enough to be wrong.
If a test is asserting that combineLatest combines the latest values, it is testing Apple’s
framework, and it will keep passing while your own logic breaks.