|
168 | 168 | private let toLocalState: (State) -> LocalState |
169 | 169 |
|
170 | 170 | private init( |
| 171 | + environment: Environment, |
| 172 | + fromLocalAction: @escaping (LocalAction) -> Action, |
171 | 173 | initialState: State, |
172 | 174 | 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 |
176 | 176 | ) { |
| 177 | + self.environment = environment |
| 178 | + self.fromLocalAction = fromLocalAction |
177 | 179 | self.state = initialState |
178 | 180 | self.reducer = reducer |
179 | | - self.environment = environment |
180 | 181 | self.toLocalState = toLocalState |
181 | | - self.fromLocalAction = fromLocalAction |
182 | 182 | } |
183 | 183 | } |
184 | 184 |
|
|
195 | 195 | environment: Environment |
196 | 196 | ) { |
197 | 197 | self.init( |
| 198 | + environment: environment, |
| 199 | + fromLocalAction: { $0 }, |
198 | 200 | initialState: initialState, |
199 | 201 | reducer: reducer, |
200 | | - environment: environment, |
201 | | - state: { $0 }, |
202 | | - action: { $0 } |
| 202 | + toLocalState: { $0 } |
203 | 203 | ) |
204 | 204 | } |
205 | 205 | } |
|
220 | 220 | file: StaticString = #file, |
221 | 221 | line: UInt = #line |
222 | 222 | ) { |
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)) |
241 | 239 | } |
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 | + ) |
248 | 261 |
|
249 | 262 | 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 | + } |
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