Home

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 .loading then .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 map or scan complex 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.