Testing The Composable Architecture (TCA) with TestStore

Testing The Composable Architecture (TCA) with TestStore

The Composable Architecture (TCA) from Point-Free is a Swift state management library for building apps in a Redux-style unidirectional data flow. One of TCA's selling points is testability: TestStore makes it straightforward to test state mutations, action sequences, and effects.

TCA in 30 Seconds

A TCA feature consists of:

  • State — a struct describing the feature's data
  • Action — an enum of all events that can occur
  • Reducer — a function that mutates state in response to actions and returns effects
  • Effect — asynchronous work (network calls, timers, etc.)
import ComposableArchitecture

@Reducer
struct CounterFeature {
    @ObservableState
    struct State: Equatable {
        var count = 0
        var isLoading = false
    }

    enum Action {
        case increment
        case decrement
        case fetchCountButtonTapped
        case fetchCountResponse(Int)
    }

    @Dependency(\.numberClient) var numberClient

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .increment:
                state.count += 1
                return .none

            case .decrement:
                state.count -= 1
                return .none

            case .fetchCountButtonTapped:
                state.isLoading = true
                return .run { send in
                    let count = try await numberClient.fetchRandom()
                    await send(.fetchCountResponse(count))
                }

            case .fetchCountResponse(let count):
                state.isLoading = false
                state.count = count
                return .none
            }
        }
    }
}

TestStore Basics

TestStore is TCA's testing API. You create it with an initial state, send actions, and assert on state mutations:

import ComposableArchitecture
import XCTest

@MainActor
final class CounterFeatureTests: XCTestCase {

    func testIncrement() async {
        let store = TestStore(initialState: CounterFeature.State()) {
            CounterFeature()
        }

        await store.send(.increment) {
            $0.count = 1
        }
    }

    func testDecrement() async {
        let store = TestStore(initialState: CounterFeature.State(count: 5)) {
            CounterFeature()
        }

        await store.send(.decrement) {
            $0.count = 4
        }
    }
}

The trailing closure in store.send(.increment) { ... } receives the expected state after the action. You mutate it to match what you expect. TestStore diffs actual vs expected and fails with a clear message on mismatch.

Testing Multiple Actions

Send multiple actions sequentially:

func testIncrementThenDecrement() async {
    let store = TestStore(initialState: CounterFeature.State()) {
        CounterFeature()
    }

    await store.send(.increment) {
        $0.count = 1
    }

    await store.send(.increment) {
        $0.count = 2
    }

    await store.send(.decrement) {
        $0.count = 1
    }
}

Each send is awaited before the next — TestStore enforces that effects complete between assertions (unless you use exhaustivity settings).

Testing Effects

For actions that trigger effects (async work), use store.receive to assert on the effect's response action:

func testFetchCount() async {
    let store = TestStore(initialState: CounterFeature.State()) {
        CounterFeature()
    } withDependencies: {
        $0.numberClient.fetchRandom = { 42 }  // override with test value
    }

    await store.send(.fetchCountButtonTapped) {
        $0.isLoading = true
    }

    await store.receive(\.fetchCountResponse) {
        $0.isLoading = false
        $0.count = 42
    }
}

store.receive(\.fetchCountResponse) waits for an action matching that case to be received from the effect, then asserts on the state after it's processed.

Overriding Dependencies

TCA's dependency system (@Dependency) is designed for testing. Override dependencies in TestStore's withDependencies closure:

func testNetworkError() async {
    let store = TestStore(initialState: CounterFeature.State()) {
        CounterFeature()
    } withDependencies: {
        $0.numberClient.fetchRandom = {
            throw NetworkError.timeout
        }
    }

    await store.send(.fetchCountButtonTapped) {
        $0.isLoading = true
    }

    // The error is swallowed or mapped — assert on the resulting state
    await store.receive(\.fetchCountResponse) { _ in
        // or check if an error state was set
    }
}

For common dependencies, TCA provides test-friendly defaults:

  • $0.clock = .immediate — clocks run instantly
  • $0.continuousClock = .immediate — same for continuous clocks
  • $0.uuid = .incrementing — deterministic UUIDs

Exhaustive vs Non-Exhaustive Testing

By default, TestStore requires every action and state change to be asserted. This exhaustive mode catches unexpected behavior but can be verbose.

For integration-style tests, disable exhaustive checking:

func testNavigationFlow() async {
    let store = TestStore(initialState: AppFeature.State()) {
        AppFeature()
    }

    store.exhaustivity = .off  // only assert what matters

    await store.send(.loginButtonTapped)
    // Skip intermediate state changes
    await store.receive(\.loginResponse.success)

    XCTAssertEqual(store.state.currentScreen, .dashboard)
}

Or use .partial to show diffs only for changes you didn't assert:

store.exhaustivity = .partial

Testing Timer-Based Effects

Use $0.clock = .immediate (or ImmediateClock()) to collapse delays:

@Reducer
struct TimerFeature {
    struct State: Equatable { var ticks = 0 }
    enum Action { case timerTicked; case startTimer }

    @Dependency(\.continuousClock) var clock

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .startTimer:
                return .run { send in
                    for await _ in clock.timer(interval: .seconds(1)) {
                        await send(.timerTicked)
                    }
                }
            case .timerTicked:
                state.ticks += 1
                return .none
            }
        }
    }
}

func testTimerTicks() async {
    let store = TestStore(initialState: TimerFeature.State()) {
        TimerFeature()
    } withDependencies: {
        $0.continuousClock = ImmediateClock()  // collapse delays
    }

    await store.send(.startTimer)

    await store.receive(.timerTicked) { $0.ticks = 1 }
    await store.receive(.timerTicked) { $0.ticks = 2 }
    await store.receive(.timerTicked) { $0.ticks = 3 }

    await store.send(.stopTimer)  // if you have a stop action
}

Testing Child Features

TCA composes reducers. Test child features in isolation, then test the parent's scoping:

// Parent
@Reducer
struct AppFeature {
    struct State {
        var counter = CounterFeature.State()
    }

    enum Action {
        case counter(CounterFeature.Action)
    }

    var body: some ReducerOf<Self> {
        Scope(state: \.counter, action: \.counter) {
            CounterFeature()
        }
    }
}

// Test the scoped action reaches the child
func testCounterIncrement_throughParent() async {
    let store = TestStore(initialState: AppFeature.State()) {
        AppFeature()
    }

    await store.send(.counter(.increment)) {
        $0.counter.count = 1
    }
}

Defining Custom Dependencies for Tests

Register custom dependencies using TCA's DependencyKey protocol:

struct NumberClient {
    var fetchRandom: () async throws -> Int
}

extension NumberClient: DependencyKey {
    static let liveValue = NumberClient {
        // Real API call
        try await URLSession.shared.data(from: url)...
    }

    static let testValue = NumberClient {
        fatalError("Override in tests with withDependencies")
    }

    static let previewValue = NumberClient {
        Int.random(in: 1...100)
    }
}

extension DependencyValues {
    var numberClient: NumberClient {
        get { self[NumberClient.self] }
        set { self[NumberClient.self] = newValue }
    }
}

liveValue runs in production, previewValue in Xcode Previews, testValue fails loudly if not overridden — preventing silent test passes with production dependencies.

Production Monitoring

TCA's TestStore tests state logic with full isolation. For monitoring that your live app's backend works correctly in production, HelpMeTest runs continuous behavioral tests against live endpoints — no simulator, no TCA knowledge required.

Summary

  • TestStore(initialState:) { Reducer() } creates a test instance
  • await store.send(.action) { $0.state = expected } asserts state mutation
  • await store.receive(\.effectResponse) { ... } asserts on effect callbacks
  • withDependencies overrides @Dependency values for each test
  • store.exhaustivity = .off for integration tests where intermediate states don't matter
  • ImmediateClock() / $0.continuousClock = .immediate collapses timer delays
  • Define testValue as fatalError to catch accidental production dependency use in tests

Read more