Skip to content

Commit

Permalink
Drive Test Store with a real Store (#278)
Browse files Browse the repository at this point in the history
* Failing test

* Drive Test Store using Store

* Update TestStore.swift

* format

* Track state after send

* Use proper snapshot

* Fix?

* fix
  • Loading branch information
stephencelis authored Sep 4, 2020
1 parent 5921620 commit 3a63b83
Show file tree
Hide file tree
Showing 4 changed files with 170 additions and 76 deletions.
14 changes: 7 additions & 7 deletions Examples/VoiceMemos/VoiceMemos/VoiceMemo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,19 @@ let voiceMemoReducer = Reducer<VoiceMemo, VoiceMemoAction, VoiceMemoEnvironment>
memo.mode = .playing(progress: 0)
let start = environment.mainQueue.now
return .merge(
environment.audioPlayerClient
.play(PlayerId(), memo.url)
.catchToEffect()
.map(VoiceMemoAction.audioPlayerClient)
.cancellable(id: PlayerId()),

Effect.timer(id: TimerId(), every: 0.5, on: environment.mainQueue)
.map {
.timerUpdated(
TimeInterval($0.dispatchTime.uptimeNanoseconds - start.dispatchTime.uptimeNanoseconds)
/ TimeInterval(NSEC_PER_SEC)
)
}
},

environment.audioPlayerClient
.play(PlayerId(), memo.url)
.catchToEffect()
.map(VoiceMemoAction.audioPlayerClient)
.cancellable(id: PlayerId())
)

case .playing:
Expand Down
9 changes: 5 additions & 4 deletions Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -178,20 +178,21 @@ class VoiceMemosTests: XCTestCase {
.send(.voiceMemo(index: 0, action: .playButtonTapped)) {
$0.voiceMemos[0].mode = VoiceMemo.Mode.playing(progress: 0)
},
.do { self.scheduler.advance(by: 1) },
.do { self.scheduler.advance(by: 0.5) },
.receive(VoiceMemosAction.voiceMemo(index: 0, action: VoiceMemoAction.timerUpdated(0.5))) {
$0.voiceMemos[0].mode = .playing(progress: 0.5)
},
.do { self.scheduler.advance(by: 0.5) },
.receive(VoiceMemosAction.voiceMemo(index: 0, action: VoiceMemoAction.timerUpdated(1))) {
$0.voiceMemos[0].mode = .playing(progress: 1)
},
.receive(
.voiceMemo(
index: 0,
action: .audioPlayerClient(.success(.didFinishPlaying(successfully: true)))
)
) {
$0.voiceMemos[0].mode = .notPlaying
},
.receive(VoiceMemosAction.voiceMemo(index: 0, action: VoiceMemoAction.timerUpdated(1))) {
$0.voiceMemos[0].mode = .notPlaying
}
)
}
Expand Down
159 changes: 94 additions & 65 deletions Sources/ComposableArchitecture/TestSupport/TestStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -168,17 +168,17 @@
private let toLocalState: (State) -> LocalState

private init(
environment: Environment,
fromLocalAction: @escaping (LocalAction) -> Action,
initialState: State,
reducer: Reducer<State, Action, Environment>,
environment: Environment,
state toLocalState: @escaping (State) -> LocalState,
action fromLocalAction: @escaping (LocalAction) -> Action
toLocalState: @escaping (State) -> LocalState
) {
self.environment = environment
self.fromLocalAction = fromLocalAction
self.state = initialState
self.reducer = reducer
self.environment = environment
self.toLocalState = toLocalState
self.fromLocalAction = fromLocalAction
}
}

Expand All @@ -195,11 +195,11 @@
environment: Environment
) {
self.init(
environment: environment,
fromLocalAction: { $0 },
initialState: initialState,
reducer: reducer,
environment: environment,
state: { $0 },
action: { $0 }
toLocalState: { $0 }
)
}
}
Expand All @@ -220,34 +220,62 @@
file: StaticString = #file,
line: UInt = #line
) {
var receivedActions: [Action] = []

var cancellables: [String: [AnyCancellable]] = [:]

func runReducer(action: Action) {
let actionKey = debugCaseOutput(action)

let effect = self.reducer.run(&self.state, action, self.environment)
var isComplete = false
var cancellable: AnyCancellable?
cancellable = effect.sink(
receiveCompletion: { _ in
isComplete = true
guard let cancellable = cancellable else { return }
cancellables[actionKey]?.removeAll(where: { $0 == cancellable })
},
receiveValue: {
receivedActions.append($0)
var receivedActions: [(action: Action, state: State)] = []
var longLivingEffects: [String: Set<UUID>] = [:]
var snapshotState = self.state

let store = Store(
initialState: self.state,
reducer: Reducer<State, TestAction, Void> { state, action, _ in
let effects: Effect<Action, Never>
switch action {
case let .send(localAction):
effects = self.reducer.run(&state, self.fromLocalAction(localAction), self.environment)
snapshotState = state

case let .receive(action):
effects = self.reducer.run(&state, action, self.environment)
receivedActions.append((action, state))
}
)
if !isComplete, let cancellable = cancellable {
cancellables[actionKey] = cancellables[actionKey] ?? []
cancellables[actionKey]?.append(cancellable)
}
}

let key = debugCaseOutput(action)
let id = UUID()
return
effects
.handleEvents(
receiveSubscription: { _ in longLivingEffects[key, default: []].insert(id) },
receiveCompletion: { _ in longLivingEffects[key]?.remove(id) },
receiveCancel: { longLivingEffects[key]?.remove(id) }
)
.map(TestAction.receive)
.eraseToEffect()

},
environment: ()
)
defer { self.state = store.state.value }

let viewStore = ViewStore(
store.scope(state: self.toLocalState, action: TestAction.send)
)

for step in steps {
var expectedState = toLocalState(state)
var expectedState = toLocalState(snapshotState)

func expectedStateShouldMatch(actualState: LocalState) {
if expectedState != actualState {
let diff =
debugDiff(expectedState, actualState)
.map { ": …\n\n\($0.indent(by: 4))\n\n(Expected: −, Actual: +)" }
?? ""
_XCTFail(
"""
State change does not match expectation\(diff)
""",
file: step.file, line: step.line
)
}
}

switch step.type {
case let .send(action, update):
Expand All @@ -262,21 +290,21 @@
file: step.file, line: step.line
)
}
runReducer(action: self.fromLocalAction(action))
viewStore.send(action)
update(&expectedState)
expectedStateShouldMatch(actualState: toLocalState(snapshotState))

case let .receive(expectedAction, update):
guard !receivedActions.isEmpty else {
_XCTFail(
"""
Expected to receive an action, but received none.
""",
file: step.file,
line: step.line
file: step.file, line: step.line
)
break
}
let receivedAction = receivedActions.removeFirst()
let (receivedAction, state) = receivedActions.removeFirst()
if expectedAction != receivedAction {
let diff =
debugDiff(expectedAction, receivedAction)
Expand All @@ -286,12 +314,12 @@
"""
Received unexpected action\(diff)
""",
file: step.file,
line: step.line
file: step.file, line: step.line
)
}
runReducer(action: receivedAction)
update(&expectedState)
expectedStateShouldMatch(actualState: toLocalState(state))
snapshotState = state

case let .environment(work):
if !receivedActions.isEmpty {
Expand All @@ -305,23 +333,21 @@
file: step.file, line: step.line
)
}

work(&self.environment)
}

let actualState = self.toLocalState(self.state)
if expectedState != actualState {
let diff =
debugDiff(expectedState, actualState)
.map { ": …\n\n\($0.indent(by: 4))\n\n(Expected: −, Actual: +)" }
?? ""
_XCTFail(
"""
State change does not match expectation\(diff)
""",
file: step.file,
line: step.line
)
case let .do(work):
if !receivedActions.isEmpty {
_XCTFail(
"""
Must handle \(receivedActions.count) received \
action\(receivedActions.count == 1 ? "" : "s") before performing this work: …
Unhandled actions: \(debugOutput(receivedActions))
""",
file: step.file, line: step.line
)
}
work()
}
}

Expand All @@ -333,12 +359,11 @@
Unhandled actions: \(debugOutput(receivedActions))
""",
file: file,
line: line
file: file, line: line
)
}

let unfinishedActions = cancellables.filter { !$0.value.isEmpty }.map { $0.key }
let unfinishedActions = longLivingEffects.filter { !$0.value.isEmpty }.map { $0.key }
if unfinishedActions.count > 0 {
let initiatingActions = unfinishedActions.map { "\($0)" }.joined(separator: "\n")
let pluralSuffix = unfinishedActions.count == 1 ? "" : "s"
Expand All @@ -363,8 +388,7 @@
ensure those effects are completed by returning an `Effect.cancel` effect from a \
particular action in your reducer, and sending that action in the test.
""",
file: file,
line: line
file: file, line: line
)
}
}
Expand All @@ -385,11 +409,11 @@
action fromLocalAction: @escaping (A) -> LocalAction
) -> TestStore<State, S, Action, A, Environment> {
.init(
environment: self.environment,
fromLocalAction: { self.fromLocalAction(fromLocalAction($0)) },
initialState: self.state,
reducer: self.reducer,
environment: self.environment,
state: { toLocalState(self.toLocalState($0)) },
action: { self.fromLocalAction(fromLocalAction($0)) }
toLocalState: { toLocalState(self.toLocalState($0)) }
)
}

Expand Down Expand Up @@ -476,15 +500,21 @@
line: UInt = #line,
_ work: @escaping () -> Void
) -> Step {
self.environment(file: file, line: line) { _ in work() }
Step(.do(work), file: file, line: line)
}

fileprivate enum StepType {
case send(LocalAction, (inout LocalState) -> Void)
case receive(Action, (inout LocalState) -> Void)
case environment((inout Environment) -> Void)
case `do`(() -> Void)
}
}

private enum TestAction {
case send(LocalAction)
case receive(Action)
}
}

// NB: Dynamically load XCTest to prevent leaking its symbols into our library code.
Expand Down Expand Up @@ -525,5 +555,4 @@
_XCTest
.flatMap { dlsym($0, "_XCTCurrentTestCase") }
.map({ unsafeBitCast($0, to: XCTCurrentTestCase.self) })

#endif
64 changes: 64 additions & 0 deletions Tests/ComposableArchitectureTests/TestStoreTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import Combine
import ComposableArchitecture
import XCTest

class TestStoreTests: XCTestCase {
func testEffectConcatenation() {
struct State: Equatable {}

enum Action: Equatable {
case a, b1, b2, b3, c1, c2, c3, d
}

let testScheduler = DispatchQueue.testScheduler

let reducer = Reducer<State, Action, AnySchedulerOf<DispatchQueue>> { _, action, scheduler in
switch action {
case .a:
return .merge(
Effect.concatenate(.init(value: .b1), .init(value: .c1))
.delay(for: 1, scheduler: scheduler)
.eraseToEffect(),
Empty(completeImmediately: false)
.eraseToEffect()
.cancellable(id: 1)
)
case .b1:
return
Effect
.concatenate(.init(value: .b2), .init(value: .b3))
case .c1:
return
Effect
.concatenate(.init(value: .c2), .init(value: .c3))
case .b2, .b3, .c2, .c3:
return .none

case .d:
return .cancel(id: 1)
}
}

let store = TestStore(
initialState: State(),
reducer: reducer,
environment: testScheduler.eraseToAnyScheduler()
)

store.assert(
.send(.a),

.do { testScheduler.advance(by: 1) },

.receive(.b1),
.receive(.b2),
.receive(.b3),

.receive(.c1),
.receive(.c2),
.receive(.c3),

.send(.d)
)
}
}

0 comments on commit 3a63b83

Please sign in to comment.