Skip to content

Commit 2f02d93

Browse files
Update docs for Effect.timer and TestStore. (#220)
* Update docs for Effect.timer and TestStore. * format * update * Update Sources/ComposableArchitecture/Effects/Timer.swift Co-authored-by: Stephen Celis <[email protected]> * Update Sources/ComposableArchitecture/TestSupport/TestStore.swift Co-authored-by: Stephen Celis <[email protected]> * wip * Update TestStore.swift Co-authored-by: Stephen Celis <[email protected]>
1 parent 4328951 commit 2f02d93

File tree

2 files changed

+138
-40
lines changed

2 files changed

+138
-40
lines changed

Sources/ComposableArchitecture/Effects/Timer.swift

Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,72 @@ import Foundation
22
import ReactiveSwift
33

44
extension Effect where Value == Date, Error == Never {
5-
/// Returns an effect that repeatedly emits the current time of the given
6-
/// scheduler on the given interval.
7-
///
8-
/// This effect serves as a testable alternative to `Timer.publish`, which
9-
/// performs its work on a run loop, _not_ a scheduler.
10-
///
11-
/// struct TimerId: Hashable {}
12-
///
13-
/// switch action {
14-
/// case .startTimer:
15-
/// return Effect.timer(id: TimerId(), every: 1, on: environment.scheduler)
16-
/// .map { .timerUpdated($0) }
17-
/// case let .timerUpdated(date):
18-
/// state.date = date
19-
/// return .none
20-
/// case .stopTimer:
21-
/// return .cancel(id: TimerId())
5+
/// Returns an effect that repeatedly emits the current time of the given scheduler on the given
6+
/// interval.
7+
///
8+
/// This is basically a wrapper around the ReactiveSwift `SignalProducer.timer` function
9+
/// and which adds the the ability to be cancelled via the `id`.
10+
///
11+
/// To start and stop a timer in your feature you can create the timer effect from an action
12+
/// and then use the `.cancel(id:)` effect to stop the timer:
13+
///
14+
/// struct AppState {
15+
/// var count = 0
16+
/// }
17+
///
18+
/// enum AppAction {
19+
/// case startButtonTapped, stopButtonTapped, timerTicked
20+
/// }
21+
///
22+
/// struct AppEnvironment {
23+
/// var mainQueue: AnySchedulerOf<DispatchQueue>
24+
/// }
25+
///
26+
/// let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, env in
27+
/// struct TimerId: Hashable {}
28+
///
29+
/// switch action {
30+
/// case .startButtonTapped:
31+
/// return Effect.timer(id: TimerId(), every: 1, on: env.mainQueue)
32+
/// .map { _ in .timerTicked }
33+
///
34+
/// case .stopButtonTapped:
35+
/// return .cancel(id: TimerId())
36+
///
37+
/// case let .timerTicked:
38+
/// state.count += 1
39+
/// return .none
40+
/// }
41+
///
42+
/// Then to test the timer in this feature you can use a test scheduler to advance time:
43+
///
44+
/// func testTimer() {
45+
/// let scheduler = TestScheduler()
46+
///
47+
/// let store = TestStore(
48+
/// initialState: .init(),
49+
/// reducer: appReducer,
50+
/// envirnoment: .init(
51+
/// mainQueue: scheduler
52+
/// )
53+
/// )
54+
///
55+
/// store.assert(
56+
/// .send(.startButtonTapped),
57+
///
58+
/// .do { scheduler.advance(by: .seconds(1)) },
59+
/// .receive(.timerTicked) { $0.count = 1 },
60+
///
61+
/// .do { scheduler.advance(by: .seconds(5)) },
62+
/// .receive(.timerTicked) { $0.count = 2 },
63+
/// .receive(.timerTicked) { $0.count = 3 },
64+
/// .receive(.timerTicked) { $0.count = 4 },
65+
/// .receive(.timerTicked) { $0.count = 5 },
66+
/// .receive(.timerTicked) { $0.count = 6 },
67+
///
68+
/// .send(.stopButtonTapped)
69+
/// )
70+
/// }
2271
///
2372
/// - Parameters:
2473
/// - interval: The time interval on which to publish events. For example, a value of `0.5`

Sources/ComposableArchitecture/TestSupport/TestStore.swift

Lines changed: 72 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,55 @@
44

55
/// A testable runtime for a reducer.
66
///
7+
/// This object aids in writing expressive and exhaustive tests for features built in the
8+
/// Composable Architecture. It allows you to send a sequence of actions to the store, and each
9+
/// step of the way you must assert exactly how state changed, and how effect emissions were fed
10+
/// back into the system.
11+
///
12+
/// There are multiple ways the test store forces you to exhaustively assert on how your feature
13+
/// behaves:
14+
///
15+
/// * After each action is sent you must describe precisely how the state changed from before the
16+
/// action was sent to after it was sent.
17+
///
18+
/// If even the smallest piece of data differs the test will fail. This guarantees that you are
19+
/// proving you know precisely how the state of the system changes.
20+
///
21+
/// * Sending an action can sometimes cause an effect to be executed, and if that effect emits an
22+
/// action that is fed back into the system, you **must** explicitly assert that you expect to
23+
/// receive that action from the effect, _and_ you must assert how state changed as a result.
24+
///
25+
/// If you try to send another action before you have handled all effect emissions the assertion
26+
/// will fail. This guarantees that you do not accidentally forget about an effect emission, and
27+
/// that the sequence of steps you are describing will mimic how the application behaves in
28+
/// reality.
29+
///
30+
/// * All effects must complete by the time the assertion has finished running the steps you
31+
/// specify.
32+
///
33+
/// If at the end of the assertion there is still an in-flight effect running, the assertion
34+
/// will fail. This helps exhaustively prove that you know what effects are in flight and forces
35+
/// you to prove that effects will not cause any future changes to your state.
36+
///
737
/// For example, given a simple counter reducer:
838
///
39+
/// struct CounterState {
40+
/// var count = 0
41+
/// }
42+
///
943
/// enum CounterAction: Equatable {
1044
/// case decrementButtonTapped
1145
/// case incrementButtonTapped
1246
/// }
1347
///
14-
/// let counterReducer = Reducer<Int, CounterAction, Void> { count, action, _ in
48+
/// let counterReducer = Reducer<CounterState, CounterAction, Void> { state, action, _ in
1549
/// switch action {
1650
/// case .decrementButtonTapped:
17-
/// count -= 1
51+
/// state.count -= 1
1852
/// return .none
53+
///
1954
/// case .incrementButtonTapped:
20-
/// count += 1
55+
/// state.count += 1
2156
/// return .none
2257
/// }
2358
/// }
@@ -27,50 +62,56 @@
2762
/// class CounterTests: XCTestCase {
2863
/// func testCounter() {
2964
/// let store = TestStore(
30-
/// initialState: 0, // GIVEN counter state of 0
65+
/// initialState: .init(count: 0), // GIVEN counter state of 0
3166
/// reducer: counterReducer,
3267
/// environment: ()
3368
/// )
3469
///
3570
/// store.assert(
3671
/// .send(.incrementButtonTapped) { // WHEN the increment button is tapped
37-
/// $0 = 1 // THEN the count should be 1
72+
/// $0.count = 1 // THEN the count should be 1
3873
/// }
3974
/// )
4075
/// }
4176
/// }
4277
///
43-
/// For a more complex example, including timing and effects, consider the following bare-bones
44-
/// search feature:
78+
/// Note that in the trailing closure of `.send(.incrementButtonTapped)` we are given a single
79+
/// mutable value of the state before the action was sent, and it is our job to mutate the value
80+
/// to match the state after the action was sent. In this case the `count` field changes to `1`.
81+
///
82+
/// For a more complex example, consider the following bare-bones search feature that uses the
83+
/// `.debounce` operator to wait for the user to stop typing before making a network request:
4584
///
4685
/// struct SearchState: Equatable {
4786
/// var query = ""
4887
/// var results: [String] = []
4988
/// }
89+
///
5090
/// enum SearchAction: Equatable {
5191
/// case queryChanged(String)
5292
/// case response([String])
5393
/// }
94+
///
5495
/// struct SearchEnvironment {
5596
/// var mainQueue: DateScheduler
5697
/// var request: (String) -> Effect<[String], Never>
5798
/// }
58-
/// let searchReducer = Reducer<
59-
/// SearchState, SearchAction, SearchEnvironment
60-
/// > { state, action, environment in
6199
///
62-
/// // A local identifier for debouncing and canceling the search request effect.
63-
/// struct SearchId: Hashable {}
100+
/// let searchReducer = Reducer<SearchState, SearchAction, SearchEnvironment> {
101+
/// state, action, environment in
64102
///
65-
/// switch action {
66-
/// case let .queryChanged(query):
67-
/// state.query = query
68-
/// return environment.request(self.query)
69-
/// .debounce(id: SearchId(), interval: 0.5, scheduler: environment.mainQueue)
70-
/// case let .response(results):
71-
/// state.results = results
72-
/// return .none
73-
/// }
103+
/// struct SearchId: Hashable {}
104+
///
105+
/// switch action {
106+
/// case let .queryChanged(query):
107+
/// state.query = query
108+
/// return environment.request(self.query)
109+
/// .debounce(id: SearchId(), interval: 0.5, scheduler: environment.mainQueue)
110+
///
111+
/// case let .response(results):
112+
/// state.results = results
113+
/// return .none
114+
/// }
74115
/// }
75116
///
76117
/// It can be fully tested by controlling the environment's scheduler and effect:
@@ -82,8 +123,7 @@
82123
/// initialState: SearchState(),
83124
/// reducer: searchReducer,
84125
/// environment: SearchEnvironment(
85-
/// // Wrap the test scheduler in a type-erased scheduler
86-
/// mainQueue: scheduler.eraseToAnyScheduler(),
126+
/// mainQueue: scheduler,
87127
/// // Simulate a search response with one item
88128
/// request: { _ in Effect(value: ["Composable Architecture"]) }
89129
/// )
@@ -94,22 +134,31 @@
94134
/// // Assert that state updates accordingly
95135
/// $0.query = "c"
96136
/// },
137+
///
97138
/// // Advance the scheduler by a period shorter than the debounce
98139
/// .do { scheduler.advance(by: .millseconds(250)) },
140+
///
99141
/// // Change the query again
100142
/// .send(.searchFieldChanged("co") {
101143
/// $0.query = "co"
102144
/// },
145+
///
103146
/// // Advance the scheduler by a period shorter than the debounce
104147
/// .do { scheduler.advance(by: .millseconds(250)) },
105148
/// // Advance the scheduler to the debounce
106149
/// .do { scheduler.advance(by: .millseconds(250)) },
150+
///
107151
/// // Assert that the expected response is received
108152
/// .receive(.response(["Composable Architecture"])) {
109153
/// // Assert that state updates accordingly
110154
/// $0.results = ["Composable Architecture"]
111155
/// }
112156
/// )
157+
///
158+
/// This test is proving that the debounced network requests are correctly canceled when we do not
159+
/// wait longer than the 0.5 seconds, because if it wasn't and it delivered an action when we did
160+
/// not expect it would cause a test failure.
161+
///
113162
public final class TestStore<State, LocalState, Action: Equatable, LocalAction, Environment> {
114163
private var environment: Environment
115164
private let fromLocalAction: (LocalAction) -> Action

0 commit comments

Comments
 (0)