CombineFeedbackTest
August 27, 2026 · View on GitHub
Ergonomic testing for CombineFeedback state machines.
CombineFeedbackTest drives the real Store and feedback loop, with
dependencies mocked through your machine's initializer, and lets you assert by
waiting for the state the user would observe.
Philosophy: test the destination, not the journey
A TestStore test answers one question: given these events, did the system
arrive at the state the user would see, and did it call the dependencies it
should have?
- Destination, not journey. You assert the states that are real product requirements (a spinner appears, then data loads), not every intermediate transition. Tests stay readable and survive refactors of how the machine gets there.
- Side effects are asserted by your mocks. Inject stubs/spies through the machine's initializer and assert on them (e.g. "fetch was called once").
- Real loop, high fidelity. The reducer and the feedbacks run, so a test catches mis-wired feedbacks (wrong key path, wrong event), not just reducer logic.
Setup
Add the product to your test target:
.testTarget(
name: "MyFeatureTests",
dependencies: [
"MyFeature",
.product(name: "CombineFeedbackTest", package: "CombineFeedback"),
]
)
API
let store = TestStore(initial: State, machine: SomeMachine)
store.send(_ event: Event) // drive the loop
await store.wait(timeout:until:) { state in Bool } // suspend until a state is reached
store.state // the current state
waitcompletes the instant the loop reaches a matching state — with immediate mocks that's microseconds.timeoutdefaults to 100ms and is per-call overridable. A passing test never spends it; the timeout only fires when a machine never arrives (a real bug), reporting a failure at the call site.
A worked example
A fetch + pagination machine, with fetch mocked through the initializer:
struct Movies: StateMachine {
let fetch: (Int) async throws -> [Movie]
// ... State (idle / loading / loaded), Event, reducer, OnChange(of: \.nextPage) ...
}
func test_appearing_loads_movies() async {
let store = TestStore(
initial: Movies.State(),
machine: Movies(fetch: { page in [Movie(id: page)] }) // immediate mock
)
store.send(.fetchNext)
await store.wait { \$0.status == .idle && !\$0.movies.isEmpty }
XCTAssertEqual(store.state.movies, [Movie(id: 1)])
}
No State: Equatable requirement is imposed by the framework — wait works on
a predicate. (You'll often want Equatable for your own XCTAssertEqual.)
Three ways to test
The journey-states you care about are simply destinations you choose to wait
for. How many you pin is the only difference between these styles.
1. Destination — assert the end state
store.send(.fetchNext)
await store.wait { \$0.status == .idle && !\$0.movies.isEmpty }
2. Ordered waypoints — pin the states that are real requirements
Sequential waits enforce order; intermediate states you don't name stay
invisible and refactorable. Useful when "show a spinner before data" is a
genuine requirement:
store.send(.fetchNext)
await store.wait { \$0.status == .loading } // the spinner must appear...
await store.wait { \$0.status == .idle } // ...before data lands
store.send(.retry)
await store.wait { \$0.status == .idle && !\$0.movies.isEmpty }
3. Asserting side effects
The framework asserts state; your injected mock asserts calls:
let calls = LockedCount()
let store = TestStore(
initial: Movies.State(),
machine: Movies(fetch: { page in calls.increment(); return [Movie(id: page)] })
)
store.send(.fetchNext)
await store.wait { \$0.status == .idle }
XCTAssertEqual(calls.value, 1) // debounced/deduped exactly once
How wait reports failures
When a predicate is never satisfied before timeout, wait reports a test
issue at the call site via
swift-issue-reporting,
so it surfaces natively in both XCTest and Swift Testing — no try
required. The message includes the last state and the observed trajectory
(rendered with swift-custom-dump),
so you can see where the machine got stuck:
wait(until:) timed out after 100ms — predicate never satisfied.
Last state: Movies.State(status: .loading, movies: [])
Observed trajectory:
Movies.State(status: .idle, movies: [])
Movies.State(status: .loading, movies: []) ← stuck here; the mock never resolved?
The trajectory is recorded only for diagnostics; it never affects a passing test.
Time, schedulers and clocks
TestStore is clock-agnostic — controlling time is your concern, not the
framework's. For debounce / throttle / polling, inject a scheduler or clock
through the machine's initializer and advance it in the test:
let scheduler = DispatchQueue.test // from combine-schedulers
let store = TestStore(
initial: Search.State(),
machine: Search(fetch: ..., scheduler: scheduler.eraseToAnyScheduler())
)
store.send(.queryChanged("sw"))
store.send(.queryChanged("swift"))
scheduler.advance(by: .milliseconds(300)) // advance BEFORE you await...
await store.wait { \$0.results.isEmpty == false } // ...or wait parks before time moves
Order matters: advance the scheduler before
await store.wait(...). Awaitsuspends the test, so anyadvancewritten after it never runs and the wait will simply time out.
Notes
- The timeout default (100ms) is generous enough to absorb executor jitter on loaded CI while still failing fast on a genuinely stuck machine. Bump it per-call if needed.
TestStoreisSendableand drives the productionStore, so anything you can express in production you can test here.