Skip to content

Commit 3a63b83

Browse files
authored
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 5921620 commit 3a63b83

File tree

4 files changed

+170
-76
lines changed

4 files changed

+170
-76
lines changed

Examples/VoiceMemos/VoiceMemos/VoiceMemo.swift

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,19 +63,19 @@ let voiceMemoReducer = Reducer<VoiceMemo, VoiceMemoAction, VoiceMemoEnvironment>
6363
memo.mode = .playing(progress: 0)
6464
let start = environment.mainQueue.now
6565
return .merge(
66-
environment.audioPlayerClient
67-
.play(PlayerId(), memo.url)
68-
.catchToEffect()
69-
.map(VoiceMemoAction.audioPlayerClient)
70-
.cancellable(id: PlayerId()),
71-
7266
Effect.timer(id: TimerId(), every: 0.5, on: environment.mainQueue)
7367
.map {
7468
.timerUpdated(
7569
TimeInterval($0.dispatchTime.uptimeNanoseconds - start.dispatchTime.uptimeNanoseconds)
7670
/ TimeInterval(NSEC_PER_SEC)
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
@@ -178,20 +178,21 @@ class VoiceMemosTests: XCTestCase {
178178
.send(.voiceMemo(index: 0, action: .playButtonTapped)) {
179179
$0.voiceMemos[0].mode = VoiceMemo.Mode.playing(progress: 0)
180180
},
181-
.do { self.scheduler.advance(by: 1) },
181+
.do { self.scheduler.advance(by: 0.5) },
182182
.receive(VoiceMemosAction.voiceMemo(index: 0, action: VoiceMemoAction.timerUpdated(0.5))) {
183183
$0.voiceMemos[0].mode = .playing(progress: 0.5)
184184
},
185+
.do { self.scheduler.advance(by: 0.5) },
186+
.receive(VoiceMemosAction.voiceMemo(index: 0, action: VoiceMemoAction.timerUpdated(1))) {
187+
$0.voiceMemos[0].mode = .playing(progress: 1)
188+
},
185189
.receive(
186190
.voiceMemo(
187191
index: 0,
188192
action: .audioPlayerClient(.success(.didFinishPlaying(successfully: true)))
189193
)
190194
) {
191195
$0.voiceMemos[0].mode = .notPlaying
192-
},
193-
.receive(VoiceMemosAction.voiceMemo(index: 0, action: VoiceMemoAction.timerUpdated(1))) {
194-
$0.voiceMemos[0].mode = .notPlaying
195196
}
196197
)
197198
}

Sources/ComposableArchitecture/TestSupport/TestStore.swift

Lines changed: 94 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -168,17 +168,17 @@
168168
private let toLocalState: (State) -> LocalState
169169

170170
private init(
171+
environment: Environment,
172+
fromLocalAction: @escaping (LocalAction) -> Action,
171173
initialState: State,
172174
reducer: Reducer<State, Action, Environment>,
173-
environment: Environment,
174-
state toLocalState: @escaping (State) -> LocalState,
175-
action fromLocalAction: @escaping (LocalAction) -> Action
175+
toLocalState: @escaping (State) -> LocalState
176176
) {
177+
self.environment = environment
178+
self.fromLocalAction = fromLocalAction
177179
self.state = initialState
178180
self.reducer = reducer
179-
self.environment = environment
180181
self.toLocalState = toLocalState
181-
self.fromLocalAction = fromLocalAction
182182
}
183183
}
184184

@@ -195,11 +195,11 @@
195195
environment: Environment
196196
) {
197197
self.init(
198+
environment: environment,
199+
fromLocalAction: { $0 },
198200
initialState: initialState,
199201
reducer: reducer,
200-
environment: environment,
201-
state: { $0 },
202-
action: { $0 }
202+
toLocalState: { $0 }
203203
)
204204
}
205205
}
@@ -220,34 +220,62 @@
220220
file: StaticString = #file,
221221
line: UInt = #line
222222
) {
223-
var receivedActions: [Action] = []
224-
225-
var cancellables: [String: [AnyCancellable]] = [:]
226-
227-
func runReducer(action: Action) {
228-
let actionKey = debugCaseOutput(action)
229-
230-
let effect = self.reducer.run(&self.state, action, self.environment)
231-
var isComplete = false
232-
var cancellable: AnyCancellable?
233-
cancellable = effect.sink(
234-
receiveCompletion: { _ in
235-
isComplete = true
236-
guard let cancellable = cancellable else { return }
237-
cancellables[actionKey]?.removeAll(where: { $0 == cancellable })
238-
},
239-
receiveValue: {
240-
receivedActions.append($0)
223+
var receivedActions: [(action: Action, state: State)] = []
224+
var longLivingEffects: [String: Set<UUID>] = [:]
225+
var snapshotState = self.state
226+
227+
let store = Store(
228+
initialState: self.state,
229+
reducer: Reducer<State, TestAction, Void> { state, action, _ in
230+
let effects: Effect<Action, Never>
231+
switch action {
232+
case let .send(localAction):
233+
effects = self.reducer.run(&state, self.fromLocalAction(localAction), self.environment)
234+
snapshotState = state
235+
236+
case let .receive(action):
237+
effects = self.reducer.run(&state, action, self.environment)
238+
receivedActions.append((action, state))
241239
}
242-
)
243-
if !isComplete, let cancellable = cancellable {
244-
cancellables[actionKey] = cancellables[actionKey] ?? []
245-
cancellables[actionKey]?.append(cancellable)
246-
}
247-
}
240+
241+
let key = debugCaseOutput(action)
242+
let id = UUID()
243+
return
244+
effects
245+
.handleEvents(
246+
receiveSubscription: { _ in longLivingEffects[key, default: []].insert(id) },
247+
receiveCompletion: { _ in longLivingEffects[key]?.remove(id) },
248+
receiveCancel: { longLivingEffects[key]?.remove(id) }
249+
)
250+
.map(TestAction.receive)
251+
.eraseToEffect()
252+
253+
},
254+
environment: ()
255+
)
256+
defer { self.state = store.state.value }
257+
258+
let viewStore = ViewStore(
259+
store.scope(state: self.toLocalState, action: TestAction.send)
260+
)
248261

249262
for step in steps {
250-
var expectedState = toLocalState(state)
263+
var expectedState = toLocalState(snapshotState)
264+
265+
func expectedStateShouldMatch(actualState: LocalState) {
266+
if expectedState != actualState {
267+
let diff =
268+
debugDiff(expectedState, actualState)
269+
.map { ": …\n\n\($0.indent(by: 4))\n\n(Expected: −, Actual: +)" }
270+
?? ""
271+
_XCTFail(
272+
"""
273+
State change does not match expectation\(diff)
274+
""",
275+
file: step.file, line: step.line
276+
)
277+
}
278+
}
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)