Missing state transitions #903
-
Let's imagine we have: protocol Service {
func data() -> AnyPublisher<Void, Never>
}
enum State {
case idle, loading, loaded
}
enum Action {
case load, loaded
}
let reducer = Reducer<State, Action, Service> { state, action, env in
switch state {
case .idle:
switch action {
case .load:
state = .loading
return env.data()
.map { .loaded }
.eraseToEffect()
default:
return .none
}
case .loading:
switch action {
case .loaded:
state = .loaded
return .none
default:
return .none
}
case .loaded:
return .none
}
} These entities can be used to represent a simple state machine that:
Now let's say that we want to test this scenario and we use a mocked struct MockService: Service {
func data() -> AnyPublisher<Void, Never> {
return Just(()).eraseToAnyPublisher()
}
} By using this test: let store = Store(
initialState: .idle,
reducer: reducer,
environment: MockService()
)
var emissions: [State] = []
let viewStore = ViewStore(store)
viewStore.publisher
.sink { emissions.append($0) }
.store(in: &self.cancellables)
viewStore.send(.load)
XCTAssertNoDifference(emissions, [.idle, .loading, .loaded]) we can see that it is failing because Now, this is not an issue with the real func send(_ action: Action, originatingFrom originatingAction: Action? = nil) {
self.threadCheck(status: .send(action, originatingAction: originatingAction))
self.bufferedActions.append(action)
guard !self.isSending else { return }
self.isSending = true
var currentState = self.state.value
defer {
self.isSending = false
self.state.value = currentState
}
while !self.bufferedActions.isEmpty {
let action = self.bufferedActions.removeFirst()
let effect = self.reducer(¤tState, action)
var didComplete = false
let uuid = UUID()
let effectCancellable = effect.sink(
receiveCompletion: { [weak self] _ in
self?.threadCheck(status: .effectCompletion(action))
didComplete = true
self?.effectCancellables[uuid] = nil
},
receiveValue: { [weak self] effectAction in
self?.send(effectAction, originatingFrom: action)
}
)
if !didComplete {
self.effectCancellables[uuid] = effectCancellable
} else if !bufferedActions.isEmpty {
self.state.value = currentState
}
}
} my test passes but |
Beta Was this translation helpful? Give feedback.
Replies: 2 comments 1 reply
-
This behaviour is to reduce the number of unnecessary emissions from the publisher. If all state mutations occur synchronously there is no need for subscribers to know about intermediate states. Only the end state matters. In your test, if you have an effect that returns a result immediately it makes complete sense that the state transitions directly from idle to loaded…there was no “loading” state because the load was immediate. To that end the test is correct. If you want to test the flow from idle to loading to loaded, then it would be more realistic to have an effect that allows you to control when it outputs a value using e.g. a PassthroughSubject, erased to an effect and testing the flow that way. |
Beta Was this translation helpful? Give feedback.
-
@lukeredpath Thanks for answering and for the link to the PR (I missed it)!
I get it but, should
This makes testing a bit more complicated because, as you are suggesting, PassthroughSubject would be needed and test will have |
Beta Was this translation helpful? Give feedback.
This behaviour is to reduce the number of unnecessary emissions from the publisher.
#619
If all state mutations occur synchronously there is no need for subscribers to know about intermediate states. Only the end state matters.
In your test, if you have an effect that returns a result immediately it makes complete sense that the state transitions directly from idle to loaded…there was no “loading” state because the load was immediate. To that end the test is correct.
If you want to test the flow from idle to loading to loaded, then it would be more realistic to have an effect that allows you to control when it outputs a value using e.g. a PassthroughSubject, erased to an effect and testin…