We’ve spent three parts building: a feature with effects, composition and navigation and shared state. What’s left is the part that holds all of it up, which is how you actually test this, and the question that matters before putting TCA into a project: is it worth it here?
The exhaustive model
We saw TestStore back in part 1. What I didn’t spell out is the full implication of it being exhaustive by default: every state change and every effect must be declared in the test, or the test fails.
That means if somebody adds an analytics counter to the reducer tomorrow, every test for that feature fails until someone consciously decides whether that change is correct. The first time it happened to me I thought it was friction; today it looks like the most valuable property of the system, because in a normal suite that change slips through silently and nobody finds out until it causes a bug.
When the test fails, TCA shows you a diff between the state you declared and the real one. Not an XCTAssertEqual dumping two structs at you, but the difference line by line.
When exhaustiveness gets in the way
There’s one case where this becomes unbearable, and that’s integration tests across large features. If you want to verify “the user logs in and ends up on the main screen with their data loaded”, declaring the thirty intermediate changes turns the test into something that breaks with every refactor.
That’s what non-exhaustive mode is for:
store.exhaustivity = .off
await store.send(\.login.submitButtonTapped)
await store.receive(\.login.response.success)
// We only verify what matters about the final state
With .off, changes you don’t declare pass silently. You can focus on the outcome and forget the path.
There’s a third option that in practice is the best of both:
store.exhaustivity = .off(showSkippedAssertions: true)
The test passes, but the console shows you everything it skipped, greyed out. It’s an X-ray of what your feature actually does, without forcing you to declare it. Also useful when you inherit somebody else’s code and want to understand it.
My rule is simple: exhaustive for unit tests of a single feature, non-exhaustive for flow tests that cross several.
Time, under control
We already used ImmediateClock in part 1 to skip the debounce. But sometimes time is what you want to test: a timer, a retry with growing backoff, an autosave every thirty seconds.
That’s where TestClock comes in. It doesn’t remove time, it puts it under your control:
@Test
func timerCounts() async {
let clock = TestClock()
let store = await TestStore(initialState: TimerFeature.State()) {
TimerFeature()
} withDependencies: {
$0.continuousClock = clock
}
await store.send(.startTapped)
await clock.advance(by: .seconds(1))
await store.receive(\.tick) { $0.seconds = 1 }
await clock.advance(by: .seconds(1))
await store.receive(\.tick) { $0.seconds = 2 }
await store.send(.stopTapped)
}
That test verifies two seconds of behavior in microseconds, deterministically. The classic alternative, XCTestExpectation with a waitForExpectations(timeout: 3), is slow and is the number one source of flaky tests in CI.
One important difference between the two clocks: ImmediateClock makes every wait finish instantly, so an infinite timer would become a tight loop. TestClock only advances when you tell it to. For anything periodic, use TestClock.
Effects that never end
A timer, a notification subscription, a connectivity observer: effects that run indefinitely. If a test finishes with one of those still alive, TestStore fails, and rightly so, because it means the feature leaves orphaned work behind.
Two tools:
- End the effect within the test itself, like the
.stopTappedin the example. This is preferable: besides cleaning up, it verifies the feature knows how to stop. await store.finish()when the effect ends on its own and you just need to wait for it.
And a piece of advice straight from the official docs: create the store inside each test, not as a property of the class. A store shared across tests drags state along and disables part of the checks.
receive and real timing
store.receive(...) waits a tenth of a second by default for the action to arrive. If your effect depends on something you don’t fully control, you can raise it:
await store.receive(\.tick, timeout: .seconds(2)) { $0.count = 1 }
But before raising a timeout, ask yourself why you need it. Almost always it signals a dependency that wasn’t injected. A long timeout in a test is debt: it passes today, and six months from now it flickers in CI on Friday afternoons.
Comparison: TCA versus everything else
Time to be honest: each of these options solves a different set of problems well, and the choice depends on the project in front of you.
Versus MVVM with @Observable
Since iOS 17, an @Observable with properties and async methods covers an enormous amount of ground. It’s less code, it compiles faster, and any Swift developer understands it without reading documentation.
MVVM wins on speed to start, learning curve, compile times and ease of hiring.
TCA wins on impossible states (the exhaustive switch and destination enums), effect cancellation, tests that cover the effects too, and reproducible navigation.
The tipping point is usually testing. If your team doesn’t write logic tests, most of what TCA charges in ceremony never gets paid back.
Versus MVVM + Coordinators
Coordinators solve navigation, which is exactly the problem where @Presents and StackState shine. The difference is that a coordinator is an object with its own lifecycle, one more thing to keep in sync, while in TCA navigation is a value inside the state you already had.
If you already have coordinators working and tested, migrating to TCA just for navigation doesn’t pay off. If you’re starting out and you know there will be deep links and state restoration, TCA starts ahead.
Versus home-grown Redux-like architectures
Plenty of people build their own “TCA lite”: a store, a reducer, actions. It works, and for medium-sized apps it can be the right call.
What you lose isn’t the pattern, it’s the infrastructure around it: the exhaustive TestStore, automatic effect cancellation when a screen is dismissed, @Shared with locking, the dependency system with test values. Reimplementing that properly takes months, and maintaining it is a project of its own.
When TCA is worth it
After all of this, my criteria:
Yes, when:
- The app has complex flows with many intermediate states (onboarding, checkout, long forms).
- There’s real concurrency: requests that get cancelled, retried, overlapped.
- The team writes logic tests and values them.
- There will be deep links, state restoration or programmatic navigation.
- The project will last years and pass through several hands.
No, when:
- It’s a one-to-five screen app, especially a read-only one.
- You’re solo and you’re not going to write tests. TCA’s ceremony is paid back by tests; without them, only the ceremony remains.
- The team has no time to learn it. The curve is real, and half-learned is worse than not using it.
- Compile times are already a problem in the project.
What hurts in production
To close, the things you only learn by living through them:
- Compiler errors with macros are cryptic. A wrong type inside a
@Reducercan produce an error pointing at the wrong line. You learn to read them, but the first months cost you. - Compile times grow. Every macro generates code, and with many features in one module splitting the project stops being optional sooner than you’d expect.
- The library moves. As we saw in part 1, 1.25 deprecated a significant chunk of the API. Point-Free documents every migration very well, but you have to budget time for it.
- Hiring and onboarding are slower. An experienced iOS developer gets into an MVVM project in a day; into a TCA one, in a week or two.
None of this is disqualifying. All of it is the price, and it’s only fair to know it before paying it.
Now, let’s build
That closes the conceptual part. We know how to model a feature, compose it, navigate with state, share data and test all of it without opening the simulator.
What comes next is the real measure: rebuilding the Pokédex from the previous series entirely in TCA. Same app, same API and same features, but with this architecture. That’s where you see, on real code with all its edge cases, where TCA does the work for you and where it charges you.
That’s the next project around here.
Sources: Testing (docs) · swift-composable-architecture (GitHub)