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.
Top comments (0)