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 = .partialTesting 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 instanceawait store.send(.action) { $0.state = expected }asserts state mutationawait store.receive(\.effectResponse) { ... }asserts on effect callbackswithDependenciesoverrides@Dependencyvalues for each teststore.exhaustivity = .offfor integration tests where intermediate states don't matterImmediateClock()/$0.continuousClock = .immediatecollapses timer delays- Define
testValueasfatalErrorto catch accidental production dependency use in tests