|
167 | 167 | private let toLocalState: (State) -> LocalState
|
168 | 168 |
|
169 | 169 | private init(
|
| 170 | + environment: Environment, |
| 171 | + fromLocalAction: @escaping (LocalAction) -> Action, |
170 | 172 | initialState: State,
|
171 | 173 | 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 |
175 | 175 | ) {
|
| 176 | + self.environment = environment |
| 177 | + self.fromLocalAction = fromLocalAction |
176 | 178 | self.state = initialState
|
177 | 179 | self.reducer = reducer
|
178 |
| - self.environment = environment |
179 | 180 | self.toLocalState = toLocalState
|
180 |
| - self.fromLocalAction = fromLocalAction |
181 | 181 | }
|
182 | 182 | }
|
183 | 183 |
|
|
194 | 194 | environment: Environment
|
195 | 195 | ) {
|
196 | 196 | self.init(
|
| 197 | + environment: environment, |
| 198 | + fromLocalAction: { $0 }, |
197 | 199 | initialState: initialState,
|
198 | 200 | reducer: reducer,
|
199 |
| - environment: environment, |
200 |
| - state: { $0 }, |
201 |
| - action: { $0 } |
| 201 | + toLocalState: { $0 } |
202 | 202 | )
|
203 | 203 | }
|
204 | 204 | }
|
|
219 | 219 | file: StaticString = #file,
|
220 | 220 | line: UInt = #line
|
221 | 221 | ) {
|
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() |
223 | 251 |
|
224 |
| - var cancellables: [String: [Disposable]] = [:] |
| 252 | + }, |
| 253 | + environment: () |
| 254 | + ) |
| 255 | + defer { self.state = store.state.value } |
225 | 256 |
|
226 |
| - func runReducer(action: Action) { |
227 |
| - let actionKey = debugCaseOutput(action) |
| 257 | + let viewStore = ViewStore( |
| 258 | + store.scope(state: self.toLocalState, action: TestAction.send) |
| 259 | + ) |
228 | 260 |
|
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) |
232 | 263 |
|
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 | + ) |
241 | 276 | }
|
242 | 277 | }
|
243 |
| - if !isComplete, let cancellable = cancellable { |
244 |
| - cancellables[actionKey] = cancellables[actionKey] ?? [] |
245 |
| - cancellables[actionKey]?.append(cancellable) |
246 | 278 | }
|
247 |
| - } |
248 |
| - |
249 |
| - for step in steps { |
250 |
| - var expectedState = toLocalState(state) |
251 | 279 |
|
252 | 280 | switch step.type {
|
253 | 281 | case let .send(action, update):
|
|
262 | 290 | file: step.file, line: step.line
|
263 | 291 | )
|
264 | 292 | }
|
265 |
| - runReducer(action: self.fromLocalAction(action)) |
| 293 | + viewStore.send(action) |
266 | 294 | update(&expectedState)
|
| 295 | + expectedStateShouldMatch(actualState: toLocalState(snapshotState)) |
267 | 296 |
|
268 | 297 | case let .receive(expectedAction, update):
|
269 | 298 | guard !receivedActions.isEmpty else {
|
270 | 299 | _XCTFail(
|
271 | 300 | """
|
272 | 301 | Expected to receive an action, but received none.
|
273 | 302 | """,
|
274 |
| - file: step.file, |
275 |
| - line: step.line |
| 303 | + file: step.file, line: step.line |
276 | 304 | )
|
277 | 305 | break
|
278 | 306 | }
|
279 |
| - let receivedAction = receivedActions.removeFirst() |
| 307 | + let (receivedAction, state) = receivedActions.removeFirst() |
280 | 308 | if expectedAction != receivedAction {
|
281 | 309 | let diff =
|
282 | 310 | debugDiff(expectedAction, receivedAction)
|
|
286 | 314 | """
|
287 | 315 | Received unexpected action\(diff)
|
288 | 316 | """,
|
289 |
| - file: step.file, |
290 |
| - line: step.line |
| 317 | + file: step.file, line: step.line |
291 | 318 | )
|
292 | 319 | }
|
293 |
| - runReducer(action: receivedAction) |
294 | 320 | update(&expectedState)
|
| 321 | + expectedStateShouldMatch(actualState: toLocalState(state)) |
| 322 | + snapshotState = state |
295 | 323 |
|
296 | 324 | case let .environment(work):
|
297 | 325 | if !receivedActions.isEmpty {
|
|
305 | 333 | file: step.file, line: step.line
|
306 | 334 | )
|
307 | 335 | }
|
308 |
| - |
309 | 336 | work(&self.environment)
|
310 |
| - } |
311 | 337 |
|
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() |
325 | 351 | }
|
326 | 352 | }
|
327 | 353 |
|
|
333 | 359 |
|
334 | 360 | Unhandled actions: \(debugOutput(receivedActions))
|
335 | 361 | """,
|
336 |
| - file: file, |
337 |
| - line: line |
| 362 | + file: file, line: line |
338 | 363 | )
|
339 | 364 | }
|
340 | 365 |
|
341 |
| - let unfinishedActions = cancellables.filter { !$0.value.isEmpty }.map { $0.key } |
| 366 | + let unfinishedActions = longLivingEffects.filter { !$0.value.isEmpty }.map { $0.key } |
342 | 367 | if unfinishedActions.count > 0 {
|
343 | 368 | let initiatingActions = unfinishedActions.map { "• \($0)" }.joined(separator: "\n")
|
344 | 369 | let pluralSuffix = unfinishedActions.count == 1 ? "" : "s"
|
|
363 | 388 | ensure those effects are completed by returning an `Effect.cancel` effect from a \
|
364 | 389 | particular action in your reducer, and sending that action in the test.
|
365 | 390 | """,
|
366 |
| - file: file, |
367 |
| - line: line |
| 391 | + file: file, line: line |
368 | 392 | )
|
369 | 393 | }
|
370 | 394 | }
|
|
385 | 409 | action fromLocalAction: @escaping (A) -> LocalAction
|
386 | 410 | ) -> TestStore<State, S, Action, A, Environment> {
|
387 | 411 | .init(
|
| 412 | + environment: self.environment, |
| 413 | + fromLocalAction: { self.fromLocalAction(fromLocalAction($0)) }, |
388 | 414 | initialState: self.state,
|
389 | 415 | 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)) } |
393 | 417 | )
|
394 | 418 | }
|
395 | 419 |
|
|
476 | 500 | line: UInt = #line,
|
477 | 501 | _ work: @escaping () -> Void
|
478 | 502 | ) -> Step {
|
479 |
| - self.environment(file: file, line: line) { _ in work() } |
| 503 | + Step(.do(work), file: file, line: line) |
480 | 504 | }
|
481 | 505 |
|
482 | 506 | fileprivate enum StepType {
|
483 | 507 | case send(LocalAction, (inout LocalState) -> Void)
|
484 | 508 | case receive(Action, (inout LocalState) -> Void)
|
485 | 509 | case environment((inout Environment) -> Void)
|
| 510 | + case `do`(() -> Void) |
486 | 511 | }
|
487 | 512 | }
|
| 513 | + |
| 514 | + private enum TestAction { |
| 515 | + case send(LocalAction) |
| 516 | + case receive(Action) |
| 517 | + } |
488 | 518 | }
|
489 | 519 |
|
490 | 520 | // NB: Dynamically load XCTest to prevent leaking its symbols into our library code.
|
|
525 | 555 | _XCTest
|
526 | 556 | .flatMap { dlsym($0, "_XCTCurrentTestCase") }
|
527 | 557 | .map({ unsafeBitCast($0, to: XCTCurrentTestCase.self) })
|
528 |
| - |
529 | 558 | #endif
|
0 commit comments