Point-Free TCA ep.3
- We are currently plucking a random
UUID
out of thin air by calling its initializer. - Problems
- we discussed how to test the reducers in isolation.
- showed how to test the second responsibility of the reducers: the side effects.
- We showed how to marry the reducer testing and the effect testing into one cohesive, ergonomic package.
Test
- AppState is already conform 'Equatable' Protocol, AppAction needs to conform that too.
the
TestStore
requires both the state and the actions of our domain to be equatable so that we can properly assert on how the system evolves.
assert
is deprecated
- testAddTodo
- UUID에 대한 Test 불가
- Environment 이용 - Dependency Injection
- UUID에 대한 Test 불가
struct AppEnvironment {
// for the test
var uuid: () -> UUID
}
let appReducer = AnyReducer<AppState, AppAction, AppEnvironment>.combine(
todoReducer.forEach(
state: \AppState.todos, // Key Path skip the function
action: /AppAction.todo(index:action:),
environment: { _ in TodoEnvironment() }
),
AnyReducer { state, action, environment in
switch action {
case .todo(index: let index, action: let action):
return .none
case .addButtonTapped:
state.todos.insert(Todo(id: environment.uuid()), at: 0)
return .none
}
}
)
.debug()
- in the preview
environment: AppEnvironment( uuid: UUID.init )
- in the scene delegate
environment: AppEnvironment( uuid: UUID.init )
- in the test
func testAddTodo() { let store = TestStore( initialState: AppState(), reducer: appReducer, environment: AppEnvironment( uuid: { UUID(uuidString: "DEADBEEF-DEAD-BEEF-DEAD-DEADBEEFDEAD")! } ) ) store.assert( .send(.addButtonTapped) { $0.todos = [ Todo( description: "", id: UUID(uuidString: "DEADBEEF-DEAD-BEEF-DEAD-DEADBEEFDEAD")!, isComplete: false ) ] } ) }
Feature
Sort when the todo is completed
- Couldn't do this job, in the
TodoReducer
let appReducer = AnyReducer<AppState, AppAction, AppEnvironment>.combine( todoReducer.forEach( state: \AppState.todos, // Key Path skip the function action: /AppAction.todo(index:action:), environment: { _ in TodoEnvironment() } ), AnyReducer { state, action, environment in switch action { case .todo(index: _, action: .checkboxTapped): // switch여서 순서도 중요하다. // naive way -> We couldn't guarantee that they are shuffled in their own group. // state.todos.sort { lhs, rhs in // !lhs.isComplete && rhs.isComplete // } state.todos = state.todos .enumerated() .sorted { lhs, rhs in (!lhs.element.isComplete && rhs.element.isComplete) || lhs.offset < rhs.offset } //.map { $0.element } // pluck out the offset .map(\.element) // using a key path return .none case .addButtonTapped: state.todos.insert(Todo(id: environment.uuid()), at: 0) return .none case .todo(index: let index, action: let action): return .none } } ) .debug()
Test
func testTodoSorting() {
let store = TestStore(
initialState: AppState(
todos: [
Todo(
description: "Milk",
id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!,
isComplete: false
),
Todo(
description: "Eggs",
id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!,
isComplete: false
)
]
),
reducer: appReducer,
environment: AppEnvironment(
uuid: { fatalError("unimplemented") } // leave this bc the dependency needs to be called
)
)
store.assert(
.send(.todo(index: 0, action: .checkboxTapped)) {
$0.todos = [
Todo(
description: "Eggs",
id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!,
isComplete: false
),
Todo(
description: "Milk",
id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!,
isComplete: true
)
]
}
)
}
store.assert(
.send(.todo(index: 0, action: .checkboxTapped)) {
$0.todos.swapAt(0, 1)
}
)
store.assert(
.send(.todo(index: 0, action: .checkboxTapped)) {
$0.todos = [
$0.todos[1],
$0.todos[0]
]
}
)
Effect
- Delaying the sort
- Since we are involving time here and want to do something outside the lifetime of our reducer being called, we definitely need to use
effects
- Effects are modeled in the Composable Architecture as Combine publishers that are returned from the reducer. After a reducer finishes its state mutation logic, it can return an effect publisher that will later be run by the store, and any data those effects produce will be fed back into the store so that we can react to it.
- We can’t return just any type of publisher, it has to be the
Effect
type that the library provides. We can still use all of the publishers and operators that Combine gives us, but at the end of the day we gotta convert that publisher to an effect, which is easy to do.
AnyReducer { state, action, environment in
switch action {
case .todo(index: _, action: .checkboxTapped): // switch여서 순서도 중요하다.
return Effect.fireAndForget {
// after a second, and then the codes below will be happened
}
.delay(for: 1, scheduler: DispatchQueue.main)
.eraseToEffect()
(...)
}
Escaping closure captures 'inout' parameter 'state'
- Add a new action :
todoDelayCompleted
enum AppAction: Equatable { case todo(index: Int, action: TodoAction) // case todoCheckboxTapped(index: Int) // case todoTextFieldChanged(index: Int, text: String) case addButtonTapped case todoDelayCompleted }
- this action are going to return in our effect
return Effect(value: AppAction.todoDelayCompleted)
- and this line means is that this reducer runs,
- and the store gets this effect,
- it will immediately execute that effect that will produce this value sending it right back into the reducer which we now have to handle down here.
AnyReducer { state, action, environment in switch action { case .todo(index: _, action: .checkboxTapped): // switch여서 순서도 중요하다. // naive way -> We couldn't guarantee that they are shuffled in their own group. // state.todos.sort { lhs, rhs in // !lhs.isComplete && rhs.isComplete // } return Effect(value: AppAction.todoDelayCompleted) state.todos = state.todos .enumerated() .sorted { lhs, rhs in (!lhs.element.isComplete && rhs.element.isComplete) || lhs.offset < rhs.offset } //.map { $0.element } // pluck out the offset .map(\.element) // using a key path return .none case .addButtonTapped: state.todos.insert(Todo(id: environment.uuid()), at: 0) return .none case .todo(index: let index, action: let action): return .none case .todoDelayCompleted: // <- here return .none }
- and we put the code here
AnyReducer { state, action, environment in switch action { case .todo(index: _, action: .checkboxTapped): // switch여서 순서도 중요하다. return Effect(value: AppAction.todoDelayCompleted) case .addButtonTapped: state.todos.insert(Todo(id: environment.uuid()), at: 0) return .none case .todo(index: let index, action: let action): return .none case .todoDelayCompleted: state.todos = state.todos .enumerated() .sorted { lhs, rhs in (!lhs.element.isComplete && rhs.element.isComplete) || lhs.offset < rhs.offset } .map(\.element) // using a key path return .none } }
- to delay the completion, add some operators
case .todo(index: _, action: .checkboxTapped): // switch여서 순서도 중요하다. return Effect(value: AppAction.todoDelayCompleted) .delay(for: 1, scheduler: DispatchQueue.main) .eraseToEffect()
- we should cancel any in-flight delayed effect that might be out there
case .todo(index: _, action: .checkboxTapped): // switch여서 순서도 중요하다. Effect.cancel(id: "completion effect") return Effect(value: AppAction.todoDelayCompleted) .delay(for: 1, scheduler: DispatchQueue.main) .eraseToEffect() .cancellable(id: "completion effect")
So what we want to do here is first cancel our todo completion effect, and then fire off a new, delayed completion effect.
- Publisher
.concatenate
case .todo(index: _, action: .checkboxTapped): return .concatenate( .cancel(id: "completion effect"), // we could erase the 'Effect' bc it's explicit Effect(value: AppAction.todoDelayCompleted) .delay(for: 1, scheduler: DispatchQueue.main) .eraseToEffect() .cancellable(id: "completion effect") )
- use another
cancellable
case .todo(index: _, action: .checkboxTapped): return Effect(value: AppAction.todoDelayCompleted) .delay(for: 1, scheduler: DispatchQueue.main) .eraseToEffect() .cancellable(id: "completion effect", cancelInFlight: true)
- The problem when using the ID as String
- if we needed to use this identifier in a few spots of our reducer we would be susceptible to typos, and that could cause subtle bugs. But, even if we extracted this string to a constant to be shared we could accidentally use the same identifier in this reducer that we were using in another reducer. Then we accidentally cancel an effect from another reducer if we happen to use the same identifier.
- then, we can
- in Swift, we can define structures in a function
- no one can access this struct out of this scope.
case .todo(index: _, action: .checkboxTapped): struct CancelDelayId: Hashable {} return Effect(value: AppAction.todoDelayCompleted) .delay(for: 1, scheduler: DispatchQueue.main) .eraseToEffect() .cancellable(id: CancelDelayId(), cancelInFlight: true)