Skip to content

Commit 459f3b1

Browse files
committed
Drive Test Store with a real Store (#278)
* Failing test * Drive Test Store using Store * Update TestStore.swift * format * Track state after send * Use proper snapshot * Fix? * fix
1 parent 5ef9b47 commit 459f3b1

File tree

4 files changed

+167
-73
lines changed

4 files changed

+167
-73
lines changed

Examples/VoiceMemos/VoiceMemos/VoiceMemo.swift

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,18 +64,18 @@ let voiceMemoReducer = Reducer<VoiceMemo, VoiceMemoAction, VoiceMemoEnvironment>
6464
memo.mode = .playing(progress: 0)
6565
let start = environment.mainQueue.currentDate
6666
return .merge(
67-
environment.audioPlayerClient
68-
.play(PlayerId(), memo.url)
69-
.catchToEffect()
70-
.map(VoiceMemoAction.audioPlayerClient)
71-
.cancellable(id: PlayerId()),
72-
7367
Effect.timer(id: TimerId(), every: .milliseconds(500), on: environment.mainQueue)
7468
.map { date -> VoiceMemoAction in
7569
.timerUpdated(
7670
date.timeIntervalSinceReferenceDate - start.timeIntervalSinceReferenceDate
7771
)
78-
}
72+
},
73+
74+
environment.audioPlayerClient
75+
.play(PlayerId(), memo.url)
76+
.catchToEffect()
77+
.map(VoiceMemoAction.audioPlayerClient)
78+
.cancellable(id: PlayerId())
7979
)
8080

8181
case .playing:

Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,20 +177,21 @@ class VoiceMemosTests: XCTestCase {
177177
.send(.voiceMemo(index: 0, action: .playButtonTapped)) {
178178
$0.voiceMemos[0].mode = VoiceMemo.Mode.playing(progress: 0)
179179
},
180-
.do { self.scheduler.advance(by: .seconds(1)) },
180+
.do { self.scheduler.advance(by: .seconds(0.5)) },
181181
.receive(VoiceMemosAction.voiceMemo(index: 0, action: VoiceMemoAction.timerUpdated(0.5))) {
182182
$0.voiceMemos[0].mode = .playing(progress: 0.5)
183183
},
184+
.do { self.scheduler.advance(by: 0.5) },
185+
.receive(VoiceMemosAction.voiceMemo(index: 0, action: VoiceMemoAction.timerUpdated(1))) {
186+
$0.voiceMemos[0].mode = .playing(progress: 1)
187+
},
184188
.receive(
185189
.voiceMemo(
186190
index: 0,
187191
action: .audioPlayerClient(.success(.didFinishPlaying(successfully: true)))
188192
)
189193
) {
190194
$0.voiceMemos[0].mode = .notPlaying
191-
},
192-
.receive(VoiceMemosAction.voiceMemo(index: 0, action: VoiceMemoAction.timerUpdated(1))) {
193-
$0.voiceMemos[0].mode = .notPlaying
194195
}
195196
)
196197
}

Sources/ComposableArchitecture/TestSupport/TestStore.swift

Lines changed: 91 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -167,17 +167,17 @@
167167
private let toLocalState: (State) -> LocalState
168168

169169
private init(
170+
environment: Environment,
171+
fromLocalAction: @escaping (LocalAction) -> Action,
170172
initialState: State,
171173
reducer: Reducer<State, Action, Environment>,
172-
environment: Environment,
173-
state toLocalState: @escaping (State) -> LocalState,
174-
action fromLocalAction: @escaping (LocalAction) -> Action
174+
toLocalState: @escaping (State) -> LocalState
175175
) {
176+
self.environment = environment
177+
self.fromLocalAction = fromLocalAction
176178
self.state = initialState
177179
self.reducer = reducer
178-
self.environment = environment
179180
self.toLocalState = toLocalState
180-
self.fromLocalAction = fromLocalAction
181181
}
182182
}
183183

@@ -194,11 +194,11 @@
194194
environment: Environment
195195
) {
196196
self.init(
197+
environment: environment,
198+
fromLocalAction: { $0 },
197199
initialState: initialState,
198200
reducer: reducer,
199-
environment: environment,
200-
state: { $0 },
201-
action: { $0 }
201+
toLocalState: { $0 }
202202
)
203203
}
204204
}
@@ -219,35 +219,63 @@
219219
file: StaticString = #file,
220220
line: UInt = #line
221221
) {
222-
var receivedActions: [Action] = []
222+
var receivedActions: [(action: Action, state: State)] = []
223+
var longLivingEffects: [String: Set<UUID>] = [:]
224+
var snapshotState = self.state
225+
226+
let store = Store(
227+
initialState: self.state,
228+
reducer: Reducer<State, TestAction, Void> { state, action, _ in
229+
let effects: Effect<Action, Never>
230+
switch action {
231+
case let .send(localAction):
232+
effects = self.reducer.run(&state, self.fromLocalAction(localAction), self.environment)
233+
snapshotState = state
234+
235+
case let .receive(action):
236+
effects = self.reducer.run(&state, action, self.environment)
237+
receivedActions.append((action, state))
238+
}
239+
240+
let key = debugCaseOutput(action)
241+
let id = UUID()
242+
return
243+
effects
244+
.handleEvents(
245+
receiveSubscription: { _ in longLivingEffects[key, default: []].insert(id) },
246+
receiveCompletion: { _ in longLivingEffects[key]?.remove(id) },
247+
receiveCancel: { longLivingEffects[key]?.remove(id) }
248+
)
249+
.map(TestAction.receive)
250+
.eraseToEffect()
223251

224-
var cancellables: [String: [Disposable]] = [:]
252+
},
253+
environment: ()
254+
)
255+
defer { self.state = store.state.value }
225256

226-
func runReducer(action: Action) {
227-
let actionKey = debugCaseOutput(action)
257+
let viewStore = ViewStore(
258+
store.scope(state: self.toLocalState, action: TestAction.send)
259+
)
228260

229-
let effect = self.reducer.run(&self.state, action, self.environment)
230-
var isComplete = false
231-
var cancellable: Disposable?
261+
for step in steps {
262+
var expectedState = toLocalState(snapshotState)
232263

233-
cancellable = effect.start { event in
234-
switch event {
235-
case .completed, .interrupted:
236-
isComplete = true
237-
guard let cancellable = cancellable else { return }
238-
cancellables[actionKey]?.removeAll(where: { $0 === cancellable })
239-
case let .value(value):
240-
receivedActions.append(value)
264+
func expectedStateShouldMatch(actualState: LocalState) {
265+
if expectedState != actualState {
266+
let diff =
267+
debugDiff(expectedState, actualState)
268+
.map { ": …\n\n\($0.indent(by: 4))\n\n(Expected: −, Actual: +)" }
269+
?? ""
270+
_XCTFail(
271+
"""
272+
State change does not match expectation\(diff)
273+
""",
274+
file: step.file, line: step.line
275+
)
241276
}
242277
}
243-
if !isComplete, let cancellable = cancellable {
244-
cancellables[actionKey] = cancellables[actionKey] ?? []
245-
cancellables[actionKey]?.append(cancellable)
246278
}
247-
}
248-
249-
for step in steps {
250-
var expectedState = toLocalState(state)
251279

252280
switch step.type {
253281
case let .send(action, update):
@@ -262,21 +290,21 @@
262290
file: step.file, line: step.line
263291
)
264292
}
265-
runReducer(action: self.fromLocalAction(action))
293+
viewStore.send(action)
266294
update(&expectedState)
295+
expectedStateShouldMatch(actualState: toLocalState(snapshotState))
267296

268297
case let .receive(expectedAction, update):
269298
guard !receivedActions.isEmpty else {
270299
_XCTFail(
271300
"""
272301
Expected to receive an action, but received none.
273302
""",
274-
file: step.file,
275-
line: step.line
303+
file: step.file, line: step.line
276304
)
277305
break
278306
}
279-
let receivedAction = receivedActions.removeFirst()
307+
let (receivedAction, state) = receivedActions.removeFirst()
280308
if expectedAction != receivedAction {
281309
let diff =
282310
debugDiff(expectedAction, receivedAction)
@@ -286,12 +314,12 @@
286314
"""
287315
Received unexpected action\(diff)
288316
""",
289-
file: step.file,
290-
line: step.line
317+
file: step.file, line: step.line
291318
)
292319
}
293-
runReducer(action: receivedAction)
294320
update(&expectedState)
321+
expectedStateShouldMatch(actualState: toLocalState(state))
322+
snapshotState = state
295323

296324
case let .environment(work):
297325
if !receivedActions.isEmpty {
@@ -305,23 +333,21 @@
305333
file: step.file, line: step.line
306334
)
307335
}
308-
309336
work(&self.environment)
310-
}
311337

312-
let actualState = self.toLocalState(self.state)
313-
if expectedState != actualState {
314-
let diff =
315-
debugDiff(expectedState, actualState)
316-
.map { ": …\n\n\($0.indent(by: 4))\n\n(Expected: −, Actual: +)" }
317-
?? ""
318-
_XCTFail(
319-
"""
320-
State change does not match expectation\(diff)
321-
""",
322-
file: step.file,
323-
line: step.line
324-
)
338+
case let .do(work):
339+
if !receivedActions.isEmpty {
340+
_XCTFail(
341+
"""
342+
Must handle \(receivedActions.count) received \
343+
action\(receivedActions.count == 1 ? "" : "s") before performing this work: …
344+
345+
Unhandled actions: \(debugOutput(receivedActions))
346+
""",
347+
file: step.file, line: step.line
348+
)
349+
}
350+
work()
325351
}
326352
}
327353

@@ -333,12 +359,11 @@
333359
334360
Unhandled actions: \(debugOutput(receivedActions))
335361
""",
336-
file: file,
337-
line: line
362+
file: file, line: line
338363
)
339364
}
340365

341-
let unfinishedActions = cancellables.filter { !$0.value.isEmpty }.map { $0.key }
366+
let unfinishedActions = longLivingEffects.filter { !$0.value.isEmpty }.map { $0.key }
342367
if unfinishedActions.count > 0 {
343368
let initiatingActions = unfinishedActions.map { "\($0)" }.joined(separator: "\n")
344369
let pluralSuffix = unfinishedActions.count == 1 ? "" : "s"
@@ -363,8 +388,7 @@
363388
ensure those effects are completed by returning an `Effect.cancel` effect from a \
364389
particular action in your reducer, and sending that action in the test.
365390
""",
366-
file: file,
367-
line: line
391+
file: file, line: line
368392
)
369393
}
370394
}
@@ -385,11 +409,11 @@
385409
action fromLocalAction: @escaping (A) -> LocalAction
386410
) -> TestStore<State, S, Action, A, Environment> {
387411
.init(
412+
environment: self.environment,
413+
fromLocalAction: { self.fromLocalAction(fromLocalAction($0)) },
388414
initialState: self.state,
389415
reducer: self.reducer,
390-
environment: self.environment,
391-
state: { toLocalState(self.toLocalState($0)) },
392-
action: { self.fromLocalAction(fromLocalAction($0)) }
416+
toLocalState: { toLocalState(self.toLocalState($0)) }
393417
)
394418
}
395419

@@ -476,15 +500,21 @@
476500
line: UInt = #line,
477501
_ work: @escaping () -> Void
478502
) -> Step {
479-
self.environment(file: file, line: line) { _ in work() }
503+
Step(.do(work), file: file, line: line)
480504
}
481505

482506
fileprivate enum StepType {
483507
case send(LocalAction, (inout LocalState) -> Void)
484508
case receive(Action, (inout LocalState) -> Void)
485509
case environment((inout Environment) -> Void)
510+
case `do`(() -> Void)
486511
}
487512
}
513+
514+
private enum TestAction {
515+
case send(LocalAction)
516+
case receive(Action)
517+
}
488518
}
489519

490520
// NB: Dynamically load XCTest to prevent leaking its symbols into our library code.
@@ -525,5 +555,4 @@
525555
_XCTest
526556
.flatMap { dlsym($0, "_XCTCurrentTestCase") }
527557
.map({ unsafeBitCast($0, to: XCTCurrentTestCase.self) })
528-
529558
#endif
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import Combine
2+
import ComposableArchitecture
3+
import XCTest
4+
5+
class TestStoreTests: XCTestCase {
6+
func testEffectConcatenation() {
7+
struct State: Equatable {}
8+
9+
enum Action: Equatable {
10+
case a, b1, b2, b3, c1, c2, c3, d
11+
}
12+
13+
let testScheduler = DispatchQueue.testScheduler
14+
15+
let reducer = Reducer<State, Action, AnySchedulerOf<DispatchQueue>> { _, action, scheduler in
16+
switch action {
17+
case .a:
18+
return .merge(
19+
Effect.concatenate(.init(value: .b1), .init(value: .c1))
20+
.delay(for: 1, scheduler: scheduler)
21+
.eraseToEffect(),
22+
Empty(completeImmediately: false)
23+
.eraseToEffect()
24+
.cancellable(id: 1)
25+
)
26+
case .b1:
27+
return
28+
Effect
29+
.concatenate(.init(value: .b2), .init(value: .b3))
30+
case .c1:
31+
return
32+
Effect
33+
.concatenate(.init(value: .c2), .init(value: .c3))
34+
case .b2, .b3, .c2, .c3:
35+
return .none
36+
37+
case .d:
38+
return .cancel(id: 1)
39+
}
40+
}
41+
42+
let store = TestStore(
43+
initialState: State(),
44+
reducer: reducer,
45+
environment: testScheduler.eraseToAnyScheduler()
46+
)
47+
48+
store.assert(
49+
.send(.a),
50+
51+
.do { testScheduler.advance(by: 1) },
52+
53+
.receive(.b1),
54+
.receive(.b2),
55+
.receive(.b3),
56+
57+
.receive(.c1),
58+
.receive(.c2),
59+
.receive(.c3),
60+
61+
.send(.d)
62+
)
63+
}
64+
}

0 commit comments

Comments
 (0)