How to test with The Composable Architecture

A photo of Dave in conversation

This blog series concludes with a look at the testability of TCA

How to test with The Composable Architecture

Testing TCA is integral but well facilitated by its design principles. TCA inherently promotes testability, offering developers a structured approach to writing tests for iOS applications. In this section, we'll delve into various testing strategies and best practices, equipping you with the knowledge and tools to ensure the reliability and stability of your TCA-based iOS apps.

Unit tests focus on verifying the behaviour of individual components in isolation, ensuring that each unit of code functions as expected. In TCA unit tests typically target reducers, ensuring that state transformations occur correctly in response to specific actions. The TestStore, provided by the TCA framework, facilitates unit testing by allowing developers to simulate state changes and action dispatches within a controlled environment.

func testIncrementAction() {
    let store = TestStore(
        initialState: FeatureStore.state(counter: 0),
        reducer: appReducer
    )

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

In the example above, we use the TestStore to create a controlled environment for testing the behaviour of the increment action on the appReducer. We simulate dispatching the increment action and verify that the counter in the application state is incremented correctly. We can also assert that expected actions are received as a result of effects.

struct State: Equatable {
    var isLoading: Bool = false
    var isError: Bool = false
    var userData: UserData?
}
enum Action {
    case loadData
    case loadDataResult(Result<UserData, Error>)
}

var body: some Reducer<State, Action> {
    Reduce { state, action in
        switch action {
        case .loadData:
            state.isLoading = true
            state.isError = false
                return .run { send in
                    let result = await Result {
                        try await apiCallToFetchData()
                    }
                    // Send new action back into the system
                    await .send(.dataLoaded(result))
                }
        case let .loadDataResult(.success(userData)):
            state.isLoading = false
            state.userData = userData
                return .none
        case .loadDataResult(.failure):
            state.isLoading = false
            state.isError = true
                return .none
        }
}

func testLoadData() async {
    let store = TestStore(
        initialState: FeatureStore.state(counter: 0),
        reducer: appReducer
    )

    await store.send(.loadData) {
        // Assert expected change to state
      $0.isLoading = true
    }
    
    // Assert reducer receives the loadDataResult action with the expected user data
    let expectedUserData = UserData()
    await store.receive(.loadDataResult(.success(expectedUserData)) {
        $0.isLoading = false
        $0.userData = expectedUserData
    }
}

In this example, we consider a store with an action that fetches data from an asynchronous source, like an API call. We make this call in the loadData action effect, which awaits the API result and returns the data by sending the loadDataResult action back into the system for the reducer to handle again.

By adopting these testing strategies and leveraging the TestStore provided by TCA, developers can test all state-driven UI and interactions fully, ensuring the reliability, stability, and maintainability of their TCA-based iOS applications.



Dependency Injection during testing

The last thing needed to create real-world apps, is how we implement the asynchronous API calls, and how we implement them in a way that allows us to continue to fully test the stores. In the testing example above we asserted that we expected the loadDataResult to return a successful expected UserData type, but how do we make sure the API call returns this every time and how do we test the loadDataResult(.failure) case where the API might fail?

In order to control what happens with these external interactions, we need to inject these services into the Store as dependencies. During tests we can then change what we are injecting into our Store with something controllable, predictable and not dependent on any outside factors to ensure our tests remain repeatable, deterministic and fast.

TCA handles dependencies using their own dependency library which has its own set of comprehensive documentation and which can be covered in other articles. The library contains many built-in dependencies such as Clock, Date, MainQueue and UUID that allow overriding with controllable versions during the test, but in our example above we want to create a dependency that handles that API call to fetch the user data.

We can register a new dependency by creating a DependencyKey protocol conformance and providing a liveValue implementation, which is used when running the app.

struct APIClient: Sendable {
    var fetchUserData: @Sendable () async throws -> UserData
}
extension APIClient: DependencyKey {
  static let liveValue = APIClient(
  /*
    Construct the "live" API client that actually makes network 
    requests and communicates with the outside world.
  */
  )
}

This immediately allows you to access this dependency from any part of the code by using an @Dependency property wrapper. In our example, FeatureStore above we add the dependency to the structure and use it in the loadData effect.

struct FeatureStore: Reducer {
    struct State: Equatable {
        var isLoading: Bool = false
        var isError: Bool = false
        var userData: UserData?
    }
    enum Action {
        case loadData
        case loadDataResult(Result<UserData, Error>)
    }
    
    @Dependency(APIClient.self) var apiClient
    
    var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
            case .loadData:
                state.isLoading = true
                state.isError = false
                    return .run { send in
                        let result = await Result {
                            try await apiClient.fetchUserData()
                        }
                        // Send new action back into the system
                        await .send(.dataLoaded(result))
                    }
            case let .loadDataResult(.success(userData)):
                state.isLoading = false
                state.userData = userData
                    return .none
            case .loadDataResult(.failure):
                state.isLoading = false
                state.isError = true
                    return .none
            }
    }
}

As well as a liveValue the library also allows us to provide a testValue implementation to be used during testing. This testValue gets even more useful when we combine it with the TestStore which provides a withDependencies closure that allows us to override the testValue any time we want.

func testLoadData() async {
    let store = TestStore(
        initialState: FeatureStore.state(counter: 0),
        reducer: appReducer
    ) withDepdendencies: {
        $0.apiClient.fetchUserData = { mockUserData }
    }

    await store.send(.loadData) {
        // Assert expected change to state
      $0.isLoading = true
    }
    
    // Assert reducer receives the loadDataResult action with the expected user data
    let expectedUserData = UserData()
    await store.receive(.loadDataResult(.success(expectedUserData)) {
        $0.isLoading = false
        $0.userData = expectedUserData
    }
}

Extending the test above we can now include a withDependencies on the TestStore that overrides the apiClient dependency fetchUserData method so a real API call isn’t made and mock data is returned. In another test, we can override the method to throw an error to mock a network error or invalid response and assert that we receive the .loadDataResult(.failure) action on the store thus fully testing all possibilities of the reducer logic.



What are the benefits of working with TCA?

TCA represents a transformative approach to iOS app development, empowering developers to confidently build robust, scalable, and maintainable applications.



Throughout this step-by-step tutorial, I hope we've demystified the core concepts of TCA, from understanding the principles of unidirectional data flow, immutability, and separation of concerns, to implementing key components such as state, actions, reducers, stores, and dependencies. By following the practical examples and best practices outlined in this tutorial, iOS developers can leverage the power of TCA to streamline their development workflow, handle user interactions and state changes effectively, and manage common scenarios such as network requests, and other asynchronous tasks with ease.

. Let's continue to innovate, collaborate, and push the boundaries of what's possible with TCA, as we embark on a journey towards building exceptional iOS experiences for users.

Click here to read more about iOS app development at Brightec.




Looking for something else?

Search over 400 blog posts from our team

Want to hear more?

Subscribe to our monthly digest of blogs to stay in the loop and come with us on our journey to make things better!