Testable Apps: Why You Should Consider The Composable Architecture
Oren Idan Yaari
Posted on May 4, 2024
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.
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
}
}
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()
}
}
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
}
}
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)
}
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
}
}
}
}
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
}
}
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.
Posted on May 4, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.