|
4 | 4 |
|
5 | 5 | /// A testable runtime for a reducer.
|
6 | 6 | ///
|
| 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 | + /// |
7 | 37 | /// For example, given a simple counter reducer:
|
8 | 38 | ///
|
| 39 | + /// struct CounterState { |
| 40 | + /// var count = 0 |
| 41 | + /// } |
| 42 | + /// |
9 | 43 | /// enum CounterAction: Equatable {
|
10 | 44 | /// case decrementButtonTapped
|
11 | 45 | /// case incrementButtonTapped
|
12 | 46 | /// }
|
13 | 47 | ///
|
14 |
| - /// let counterReducer = Reducer<Int, CounterAction, Void> { count, action, _ in |
| 48 | + /// let counterReducer = Reducer<CounterState, CounterAction, Void> { state, action, _ in |
15 | 49 | /// switch action {
|
16 | 50 | /// case .decrementButtonTapped:
|
17 |
| - /// count -= 1 |
| 51 | + /// state.count -= 1 |
18 | 52 | /// return .none
|
| 53 | + /// |
19 | 54 | /// case .incrementButtonTapped:
|
20 |
| - /// count += 1 |
| 55 | + /// state.count += 1 |
21 | 56 | /// return .none
|
22 | 57 | /// }
|
23 | 58 | /// }
|
|
27 | 62 | /// class CounterTests: XCTestCase {
|
28 | 63 | /// func testCounter() {
|
29 | 64 | /// let store = TestStore(
|
30 |
| - /// initialState: 0, // GIVEN counter state of 0 |
| 65 | + /// initialState: .init(count: 0), // GIVEN counter state of 0 |
31 | 66 | /// reducer: counterReducer,
|
32 | 67 | /// environment: ()
|
33 | 68 | /// )
|
34 | 69 | ///
|
35 | 70 | /// store.assert(
|
36 | 71 | /// .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 |
38 | 73 | /// }
|
39 | 74 | /// )
|
40 | 75 | /// }
|
41 | 76 | /// }
|
42 | 77 | ///
|
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: |
45 | 84 | ///
|
46 | 85 | /// struct SearchState: Equatable {
|
47 | 86 | /// var query = ""
|
48 | 87 | /// var results: [String] = []
|
49 | 88 | /// }
|
| 89 | + /// |
50 | 90 | /// enum SearchAction: Equatable {
|
51 | 91 | /// case queryChanged(String)
|
52 | 92 | /// case response([String])
|
53 | 93 | /// }
|
| 94 | + /// |
54 | 95 | /// struct SearchEnvironment {
|
55 | 96 | /// var mainQueue: DateScheduler
|
56 | 97 | /// var request: (String) -> Effect<[String], Never>
|
57 | 98 | /// }
|
58 |
| - /// let searchReducer = Reducer< |
59 |
| - /// SearchState, SearchAction, SearchEnvironment |
60 |
| - /// > { state, action, environment in |
61 | 99 | ///
|
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 |
64 | 102 | ///
|
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 | + /// } |
74 | 115 | /// }
|
75 | 116 | ///
|
76 | 117 | /// It can be fully tested by controlling the environment's scheduler and effect:
|
|
82 | 123 | /// initialState: SearchState(),
|
83 | 124 | /// reducer: searchReducer,
|
84 | 125 | /// environment: SearchEnvironment(
|
85 |
| - /// // Wrap the test scheduler in a type-erased scheduler |
86 |
| - /// mainQueue: scheduler.eraseToAnyScheduler(), |
| 126 | + /// mainQueue: scheduler, |
87 | 127 | /// // Simulate a search response with one item
|
88 | 128 | /// request: { _ in Effect(value: ["Composable Architecture"]) }
|
89 | 129 | /// )
|
|
94 | 134 | /// // Assert that state updates accordingly
|
95 | 135 | /// $0.query = "c"
|
96 | 136 | /// },
|
| 137 | + /// |
97 | 138 | /// // Advance the scheduler by a period shorter than the debounce
|
98 | 139 | /// .do { scheduler.advance(by: .millseconds(250)) },
|
| 140 | + /// |
99 | 141 | /// // Change the query again
|
100 | 142 | /// .send(.searchFieldChanged("co") {
|
101 | 143 | /// $0.query = "co"
|
102 | 144 | /// },
|
| 145 | + /// |
103 | 146 | /// // Advance the scheduler by a period shorter than the debounce
|
104 | 147 | /// .do { scheduler.advance(by: .millseconds(250)) },
|
105 | 148 | /// // Advance the scheduler to the debounce
|
106 | 149 | /// .do { scheduler.advance(by: .millseconds(250)) },
|
| 150 | + /// |
107 | 151 | /// // Assert that the expected response is received
|
108 | 152 | /// .receive(.response(["Composable Architecture"])) {
|
109 | 153 | /// // Assert that state updates accordingly
|
110 | 154 | /// $0.results = ["Composable Architecture"]
|
111 | 155 | /// }
|
112 | 156 | /// )
|
| 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 | + /// |
113 | 162 | public final class TestStore<State, LocalState, Action: Equatable, LocalAction, Environment> {
|
114 | 163 | private var environment: Environment
|
115 | 164 | private let fromLocalAction: (LocalAction) -> Action
|
|
0 commit comments