DEV Community

Oren Idan Yaari
Oren Idan Yaari

Posted on

Testable Apps: Why You Should Consider The Composable Architecture

Recently, I've been seeing some discussion around whether integrating TCA into our app is necessary. I wanted to take a moment to address that here.

So, why should we consider using The Composable Architecture (TCA)? Well, it's been purpose-built with testing in mind right from the start. If you're working with SwiftUI, it offers a seamless way to incorporate test coverage into your codebase. Plus, it empowers us to simulate complex user flows effortlessly. Imagine seamlessly changing a state deep within a screen and then validating that state change in its parent screen – TCA makes tasks like these a breeze.

But I get it, some might wonder if the benefits outweigh the added complexity of integrating an external library. Let's explore that with an example.

Imagine we're developing a new feature. Following Apple's recommendation to use Swift and SwiftUI, we start building our feature.
Apple quote recommendation to use Swift and SwiftUI
We create a simple ViewModel to manage some state:

class ViewModel: ObservableObject {
    struct State {
        var iLikeTest = false
    }
    @Published var state = State()

    func someFunction() {
        state.iLikeTest = true
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, let's say we want to test whether a specific action changes the state correctly:

class BlogCodeTests: XCTestCase {
    func testSomething() {
        let sut = ViewModel()
        sut.$state.sink { newState in
            XCTAssertFalse(newState.iLikeTest)
        }
        sut.someFunction()
    }
}
Enter fullscreen mode Exit fullscreen mode

This approach works, but it's not without its drawbacks. For instance, when Apple introduces a new observation macro in iOS 17 for performance enhancements, our tests break because we've removed Combine and @Published. Now there is no way to observe the state outside a SwiftUI View.

@Observable
class ViewModel {
    struct State {
        var iLikeTest = false
    }
    var state = State()

    func someFunction() {
        state.iLikeTest = true
    }
}
Enter fullscreen mode Exit fullscreen mode

To fix this, we need to refactor our tests to use a global observation function. It's a cumbersome solution and can make certain flows untestable.

func testSomething() {
        let exp = expectation(description: "fulfill onChange")
        let sut = ViewModel()
        withObservationTracking {
            sut.state.iLikeTest
        } onChange: {
            XCTAssertTrue(sut.state.iLikeTest)
            exp.fulfill()
        }
        sut.someFunction()
        waitForExpectations(timeout: 1)
    }
Enter fullscreen mode Exit fullscreen mode

This doesn't really work. The test will fail because onChange will still show the value to be false. We might be able to find a way around it, but this is where TCA comes into action. Let's take a look at how we could implement the same functionality with TCA:

@Reducer
struct MainStore {
    @ObservableState
    struct State: Equatable {
        var iLikeTest = false
    }

    enum Action {
        case someAction
    }

    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .someAction:
                state.iLikeTest = true
                return .none
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

And now, our test becomes much simpler and more robust:

func testReducer() async {
    let testStore = TestStore(initialState: .init()) {
        MainStore()
    }

    await testStore.send(.someAction) {
        $0.iLikeTest = true
    }
}
Enter fullscreen mode Exit fullscreen mode

No need to overthink it. Just call an action on a store, change to the expected state, and voila! If we don't change the state, we get a failure. If we forget to override a dependency, we get a failure. What's even better that it is all built on top of Apple's observable macros.
So, before making a decision, I'd encourage you to delve deeper into TCA and compare it against the vanilla SwiftUI approach.

Top comments (0)