SwiftTCATestingSwiftUIiOS

Pokédex in TCA (part 6): testing and a technical contrast with MVVM

Five parts in. We have the paginated list, the detail with navigation as state, favorites with @Shared and search with filters.

We close with three things: the tests we owed, the lessons from actually building the app in a real project, and a technical comparison with MVVM focused on code patterns, not on metrics.

Tests: the minimum I want to cover

TestStore is exhaustive by default: every state change and every effect must be declared, or the test fails. We already saw the pattern in detail in TCA part 4; here I apply it to the real app.

Three tests that capture the important behavior:

  • Initial load: entering the screen, calling the client, storing the Pokémon.
  • Marking a favorite: tapping the star, the shared set updating.
  • Full flow: load list, open detail, mark favorite, go back.

Test 1: initial load

import ComposableArchitecture
import Testing

@Test
func initialLoadSucceeds() async {
    let pokemons = [
        Pokemon(id: 1, name: "bulbasaur", spriteURL: nil, types: ["grass"]),
        Pokemon(id: 4, name: "charmander", spriteURL: nil, types: ["fire"]),
    ]

    let store = await TestStore(initialState: PokemonListFeature.State()) {
        PokemonListFeature()
    } withDependencies: {
        $0.continuousClock = ImmediateClock()
        $0.pokemonClient.fetchList = { _, _, _ in pokemons }
    }

    await store.send(.onAppear) {
        $0.loadState = .loading
    }

    await store.receive(\.listReceived) {
        $0.loadState = .loaded(pokemons)
        $0.canLoadMore = false   // returned fewer than the pageSize
    }
}

What happens here, line by line:

  • I swap pokemonClient.fetchList for a closure that returns my two Pokémon. No network.
  • ImmediateClock() is optional but costs nothing, and protects me in case I ever add an initial debounce: tests would keep passing instantly.
  • store.send(.onAppear) describes the reducer’s immediate change: going to .loading. If I don’t declare it, the test fails. If I declare a change that doesn’t happen, it also fails.
  • store.receive(\.listReceived) waits for the effect to deliver the action and verifies the final state.

Test 2: marking a favorite

This is where @Shared changes the shape of the test:

@Test
func markingAFavoriteUpdatesTheSharedSet() async {
    let pokemon = Pokemon(id: 25, name: "pikachu", spriteURL: nil, types: ["electric"])

    let store = await TestStore(
        initialState: PokemonDetailFeature.State(pokemon: pokemon)
    ) {
        PokemonDetailFeature()
    }

    await store.send(.favoriteTapped) {
        $0.$favorites.withLock { $0 = [25] }
    }

    await store.send(.favoriteTapped) {
        $0.$favorites.withLock { $0 = [] }
    }
}

Two things.

One, I didn’t configure any dependency. @Shared(.fileStorage) in tests uses an in-memory mock store by default, so this test starts with favorites empty and doesn’t touch real disk.

Two, $0.$favorites.withLock { $0 = [25] } in the assertion closure. Same syntax as in the reducer: the lock is part of @Shared’s contract, and tests respect it.

Test 3: full flow with reduced exhaustivity

Exhaustive tests are valuable for individual features, but when you want to verify a three-screen flow they become a chore. That’s what non-exhaustive mode is for:

@Test
func flowLoadListOpenDetailMarkFavorite() async {
    let pokemon = Pokemon(id: 1, name: "bulbasaur", spriteURL: nil, types: ["grass"])
    let detail = PokemonDetail(id: 1, height: 7, weight: 69, abilities: ["overgrow"])

    let store = await TestStore(initialState: PokemonListFeature.State()) {
        PokemonListFeature()
    } withDependencies: {
        $0.continuousClock = ImmediateClock()
        $0.pokemonClient.fetchList = { _, _, _ in [pokemon] }
        $0.pokemonClient.fetchDetail = { _ in detail }
    }
    store.exhaustivity = .off(showSkippedAssertions: true)

    await store.send(.onAppear)
    await store.receive(\.listReceived)

    await store.send(.rowTapped(pokemon))
    await store.send(.destination(.presented(.detail(.onAppear))))
    await store.receive(\.destination.presented.detail.detailReceived)

    await store.send(.destination(.presented(.detail(.favoriteTapped))))

    // Final assertion: the one thing I care about across the whole flow.
    #expect(store.state.destination?.detail?.favorites.contains(1) == true)
}

With .off(showSkippedAssertions: true) the changes I don’t declare pass silently, but the console reports them greyed out. It’s an X-ray of what the app is doing without forcing me to write every step. And the final #expect captures what I actually care about: at the end of the flow, the Pokémon is in favorites.

Lessons from building the app

These are concrete details that don’t show up reading the posts but come up right away when you implement. I’ve synced them into the earlier posts so the code stays correct.

A Button with .buttonStyle(.plain) inside a List doesn’t capture the tap

It looks like the idiomatic choice, wrapping the row in an unstyled button, but the List intercepts the tap and the action never reaches the reducer. The pattern that works is leaving the row unwrapped and adding .contentShape(Rectangle()) with .onTapGesture directly:

PokemonRow(...)
    .contentShape(Rectangle())
    .onTapGesture { store.send(.rowTapped(pokemon)) }

I found it out testing the app in the simulator: until I switched to .onTapGesture, tapping a row didn’t navigate. Xcode compiling without a peep.

Swift 6 strict mode demands Sendable discipline

The client struct, the closures it exposes and the types it returns have to be Sendable. Without that detail, static let liveValue won’t compile. Models that cross a TaskGroup (here, Pokemon when we parse in parallel) need it too.

The @Reducer enum Destination doesn’t propagate Equatable

If the parent state requires it, add the conformance in a separate extension:

extension Destination.State: Equatable {}

The old @Reducer(state: .equatable) enum syntax is deprecated.

Cancellation also throws CancellationError

When you .cancellable(id:, cancelInFlight: true) the search, each new keystroke cancels the previous one, and that cancellation propagates as CancellationError to the .run’s catch:. If you don’t filter it out, you see “cancelled” flashes on screen as the user types. Filtering is one line:

} catch: { error, send in
    if error is CancellationError { return }
    await send(.searchFailed(error.localizedDescription))
}

Technical contrast with MVVM

No metrics and no comparison table. These are the places where the same problem looks different in the code compared to MVVM.

Remote-state modeling

The usual MVVM pattern is an isLoading boolean plus an array plus an optional errorMessage. That combination allows states that shouldn’t exist (isLoading == true with data already loaded). TCA models with a LoadState<Value> enum of four mutually-exclusive cases: if you’re in .loaded(pokemons), by construction you can’t be loading or in error.

State mutation

In MVVM the ViewModel exposes methods and mutates its @Published properties directly from anywhere. In TCA the view only sends actions; the reducer decides what to mutate in an exhaustive switch on Action. Adding an action without handling it is a compile error.

Effect cancellation

In MVVM you store the request’s Task? as a property on the ViewModel, cancel it by hand in onDisappear, and every async operation is managed separately. In TCA you declare it with .cancellable(id: .search, cancelInFlight: true) and that’s it. When you present and dismiss screens with .ifLet, the child’s effects are cancelled automatically on close.

Shared state

In MVVM with SwiftData, every ViewModel that touches favorites receives the ModelContext, queries with @Query or FetchDescriptor, and refresh across views has to be coordinated by hand. In TCA @Shared(.fileStorage) is an annotation on state. You declare it in every feature that needs it, and when one changes the value, every other one sees it right away.

In MVVM NavigationLink(destination:) decides to navigate at tap time. Fast to write, hard to drive from code. In TCA the destination is an optional value in the parent state; presenting is assigning, closing is setting to nil. Everything that follows from that (deep links, restoration, flow tests) is inherited for free.

Testing

In MVVM you swap dependencies with protocols and mocks, and async waits need XCTestExpectation or Task.sleep. In TCA TestStore verifies every state change and every effect the reducer produces, @Dependency lets you swap a closure without implementing the whole interface, and tests run in microseconds without a simulator.

Project organization

MVVM tends to group by layer: Views/, ViewModels/, Services/. TCA tends to group by feature: Features/PokemonList/, Features/PokemonDetail/. Each folder contains that feature’s reducer and view, living and dying together.

When I’d pick each one

After building this Pokédex in TCA, my criteria for a new project:

I’d pick MVVM when the app has few screens and I know it won’t grow, when the team doesn’t have time to learn TCA and systematic logic tests won’t be written, or when every second of clean build compile time counts.

I’d pick TCA when the app will grow with complex navigation, deep links, or state restoration, when there’s real concurrency (requests that cancel, overlap, retry), when the team writes logic tests and maintains them, and when the project will run for years and pass through several hands.

For a Pokédex like the one in this series (5 screens, simple network, favorites), MVVM is enough and faster to build. I chose to write this series in TCA to have serious material to teach the patterns over real code, not to argue that TCA is “better”. Each architecture solves a different set of problems well.

Closing the series

That closes the Pokédex in TCA. The full code lives public in pokedex-tca, compiles with Xcode 26, iOS 17+ and Swift 6, and runs in the simulator against the real PokeAPI. You can clone it, run it, and use it as the base for your own TCA project.

For the MVVM counterpart, the PokeTracker series walks part by part through a more ambitious app (Clean Architecture with Data/Domain/Presentation, SwiftData, two-tier caching, Atomic Design). The scope isn’t symmetric with the TCA Pokédex, but it works as an advanced MVVM reference on the same family of problems.

Sources: Testing in TCA (docs) · swift-composable-architecture · app repo