@@ -7,10 +7,9 @@ The testability of features built in the Composable Architecture is the #1 prior
7
7
It should be possible to test not only how state changes when actions are sent into the store,
8
8
but also how effects are executed and feed data back into the system.
9
9
10
- <!-- * [Testing state changes][Testing-state-changes]-->
11
- <!-- * [Testing effects][Testing-effects]-->
12
- <!-- * [Designing dependencies][Designing-dependencies]-->
13
- <!-- * [Unimplemented dependencies][Unimplemented-dependencies]-->
10
+ * [ Testing state changes] [ Testing-state-changes ]
11
+ * [ Testing effects] [ Testing-effects ]
12
+ * [ Non-exhaustive testing] [ Non-exhaustive-testing ]
14
13
15
14
## Testing state changes
16
15
@@ -74,8 +73,8 @@ class CounterTests: XCTestCase {
74
73
> Tip: Test cases that use `` TestStore `` should be annotated as ` @MainActor ` and test methods should
75
74
be marked as ` async ` since most assertion helpers on `` TestStore `` can suspend.
76
75
77
- Test stores have a `` TestStore/send(_:_ :file:line:)-6s1gq `` method, but it behaves differently from
78
- stores and view stores. You provide an action to send into the system, but then you must also
76
+ Test stores have a `` TestStore/send(_:assert :file:line:)-1ax61 `` method, but it behaves differently
77
+ from stores and view stores. You provide an action to send into the system, but then you must also
79
78
provide a trailing closure to describe how the state of the feature changed after sending the
80
79
action:
81
80
@@ -95,8 +94,8 @@ await store.send(.incrementButtonTapped) {
95
94
}
96
95
```
97
96
98
- > The `` TestStore/send(_:_ :file:line:)-6s1gq `` method is ` async ` for technical reasons that we do
99
- not have to worry about right now.
97
+ > The `` TestStore/send(_:assert :file:line:)-1ax61 `` method is ` async ` for technical reasons that we
98
+ > do not have to worry about right now.
100
99
101
100
If your mutation is incorrect, meaning you perform a mutation that is different from what happened
102
101
in the `` Reducer `` , then you will get a test failure with a nicely formatted message showing exactly
@@ -138,6 +137,9 @@ await store.send(.decrementButtonTapped) {
138
137
> await store.send (.incrementButtonTapped ) {
139
138
> $0 .count += 1
140
139
> }
140
+ > await store.send (.decrementButtonTapped ) {
141
+ > $0 .count -= 1
142
+ > }
141
143
> ```
142
144
>
143
145
> … and the test would have still passed.
@@ -146,8 +148,8 @@ await store.send(.decrementButtonTapped) {
146
148
> by one, but we haven't proven we know the precise value of `count` at each step of the way.
147
149
>
148
150
> In general, the less logic you have in the trailing closure of
149
- > ``TestStore/ send (_:_ :file:line: )-6s1gq ``, the stronger your assertion will be. It is best to use
150
- > simple, hard coded data for the mutation.
151
+ > ``TestStore/ send (_:assert :file:line: )-1ax61 ``, the stronger your assertion will be. It is best to
152
+ > use simple, hard- coded data for the mutation.
151
153
152
154
Test stores do expose a ``TestStore/ state`` property, which can be useful for performing assertions
153
155
on computed properties you might have defined on your state. For example, if `State` had a
@@ -160,7 +162,7 @@ store.send(.incrementButtonTapped) {
160
162
XCTAssertTrue (store.state .isPrime )
161
163
```
162
164
163
- However, when inside the trailing closure of `` TestStore/send(_:_ :file:line:)-6s1gq `` , the
165
+ However, when inside the trailing closure of `` TestStore/send(_:assert :file:line:)-1ax61 `` , the
164
166
`` TestStore/state `` property is equal to the state _ before_ sending the action, not after. That
165
167
prevents you from being able to use an escape hatch to get around needing to actually describe the
166
168
state mutation, like so:
@@ -184,8 +186,9 @@ Location, Core Motion, Speech Recognition, etc.), and more.
184
186
185
187
As a simple example, suppose we have a feature with a button such that when you tap it, it starts
186
188
a timer that counts up until you reach 5, and then stops. This can be accomplished using the
187
- `` EffectProducer/run(priority:operation:catch:file:fileID:line:) `` helper, which provides you with
188
- an asynchronous context to operate in and can send multiple actions back into the system:
189
+ `` EffectProducer/run(priority:operation:catch:file:fileID:line:) `` helper on `` EffectTask `` ,
190
+ which provides you with an asynchronous context to operate in and can send multiple actions back
191
+ into the system:
189
192
190
193
``` swift
191
194
struct Feature : ReducerProtocol {
@@ -246,14 +249,14 @@ failure:
246
249
247
250
This is happening because `` TestStore `` requires you to exhaustively prove how the entire system
248
251
of your feature evolves over time. If an effect is still running when the test finishes and the
249
- test store does _ not_ fail then it could be hiding potential bugs. Perhaps the effect is not
252
+ test store did _ not_ fail then it could be hiding potential bugs. Perhaps the effect is not
250
253
supposed to be running, or perhaps the data it feeds into the system later is wrong. The test store
251
254
requires all effects to finish.
252
255
253
256
To get this test passing we need to assert on the actions that are sent back into the system
254
- by the effect. We do this by using the `` TestStore/receive(_:timeout:_ :file:line:)-8yd62 `` method,
255
- which allows you to assert which action you expect to receive from an effect, as well as how the
256
- state changes after receiving that effect:
257
+ by the effect. We do this by using the `` TestStore/receive(_:timeout:assert :file:line:)-1rwdd ``
258
+ method, which allows you to assert which action you expect to receive from an effect, as well as how
259
+ the state changes after receiving that effect:
257
260
258
261
``` swift
259
262
await store.receive (.timerTick ) {
@@ -269,8 +272,8 @@ going to be received, but after waiting around for a small amount of time no act
269
272
```
270
273
271
274
This is because our timer is on a 1 second interval, and by default
272
- `` TestStore/receive(_:timeout:_ :file:line:)-8yd62 `` only waits for a fraction of a second. This is
273
- because typically you should not be performing real time-based asynchrony in effects, and instead
275
+ `` TestStore/receive(_:timeout:assert :file:line:)-1rwdd `` only waits for a fraction of a second. This
276
+ is because typically you should not be performing real time-based asynchrony in effects, and instead
274
277
using a controlled entity, such as a clock, that can be sped up in tests. We will demonstrate this
275
278
in a moment, so for now let's increase the timeout:
276
279
@@ -368,15 +371,12 @@ store.dependencies.continuousClock = ImmediateClock()
368
371
```
369
372
370
373
With that small change we can drop the ` timeout ` arguments from the
371
- `` TestStore/receive(_:timeout:_ :file:line:)-8yd62 `` invocations:
374
+ `` TestStore/receive(_:timeout:assert :file:line:)-1rwdd `` invocations:
372
375
373
376
``` swift
374
377
await store.receive (.timerTick ) {
375
378
$0 .count = 1
376
379
}
377
- await store.receive (.timerTick ) {
378
- $0 .count = 1
379
- }
380
380
await store.receive (.timerTick ) {
381
381
$0 .count = 2
382
382
}
@@ -397,10 +397,169 @@ The more time you take to control the dependencies your features use, the easier
397
397
write tests for your features. To learn more about designing dependencies and how to best leverage
398
398
dependencies, read the < doc:DependencyManagement > article.
399
399
400
+ ## Non-exhaustive testing
401
+
402
+ The previous sections describe in detail how to write tests in the Composable Architecture that
403
+ exhaustively prove how the entire feature evolves over time. You must assert on how every piece
404
+ of state changes, how every effect feeds data back into the system, and you must even make sure
405
+ that all effects complete before the test store is deallocated. This can be powerful, but it can
406
+ also be a nuisance, especially for highly composed features. This is why sometimes you may want
407
+ to test in a non-exhaustive style.
408
+
409
+ > Tip: The concept of "non-exhaustive test store" was first introduced by
410
+ [ Krzysztof Zabłocki] [ merowing.info ] in a [ blog post] [ exhaustive-testing-in-tca ] and
411
+ [ conference talk] [ Composable-Architecture-at-Scale ] , and then later became integrated into the
412
+ core library.
413
+
414
+ This style of testing is most useful for testing the integration of multiple features where you want
415
+ to focus on just a certain slice of the behavior. Exhaustive testing can still be important to use
416
+ for leaf node features, where you truly do want to assert on everything happening inside the
417
+ feature.
418
+
419
+ For example, suppose you have a tab-based application where the 3rd tab is a login screen. The user
420
+ can fill in some data on the screen, then tap the "Submit" button, and then a series of events
421
+ happens to log the user in. Once the user is logged in, the 3rd tab switches from a login screen
422
+ to a profile screen, _ and_ the selected tab switches to the first tab, which is an activity screen.
423
+
424
+ When writing tests for the login feature we will want to do that in the exhaustive style so that we
425
+ can prove exactly how the feature would behave in production. But, suppose we wanted to write an
426
+ integration test that proves after the user taps the "Login" button that ultimately the selected
427
+ tab switches to the first tab.
428
+
429
+ In order to test such a complex flow we must test the integration of multiple features, which means
430
+ dealing with complex, nested state and effects. We can emulate this flow in a test by sending
431
+ actions that mimic the user logging in, and then eventually assert that the selected tab switched
432
+ to activity:
433
+
434
+ ``` swift
435
+ let store = TestStore (
436
+ initialState : App.State (),
437
+ reducer : App ()
438
+ )
439
+
440
+ // 1️⃣ Emulate user tapping on submit button.
441
+ await store.send (.login (.submitButtonTapped )) {
442
+ // 2️⃣ Assert how all state changes in the login feature
443
+ $0 .login ? .isLoading = true
444
+ …
445
+ }
446
+
447
+ // 3️⃣ Login feature performs API request to login, and
448
+ // sends response back into system.
449
+ await store.receive (.login (.loginResponse (.success ))) {
450
+ // 4️⃣ Assert how all state changes in the login feature
451
+ $0 .login ? .isLoading = false
452
+ …
453
+ }
454
+
455
+ // 5️⃣ Login feature sends a delegate action to let parent
456
+ // feature know it has successfully logged in.
457
+ await store.receive (.login (.delegate (.didLogin ))) {
458
+ // 6️⃣ Assert how all of app state changes due to that action.
459
+ $0 .authenticatedTab = .loggedIn (
460
+ Profile.State (... )
461
+ )
462
+ …
463
+ // 7️⃣ *Finally* assert that the selected tab switches to activity.
464
+ $0 .selectedTab = .activity
465
+ }
466
+ ```
467
+
468
+ Doing this with exhaustive testing is verbose, and there are a few problems with this:
469
+
470
+ * We need to be intimately knowledgeable in how the login feature works so that we can assert
471
+ on how its state changes and how its effects feed data back into the system.
472
+ * If the login feature were to change its logic we may get test failures here even though the logic
473
+ we are acutally trying to test doesn't really care about those changes.
474
+ * This test is very long, and so if there are other similar but slightly different flows we want to
475
+ test we will be tempted to copy-and-paste the whole thing, leading to lots of duplicated, fragile
476
+ tests.
477
+
478
+ Non-exhaustive testing allows us to test the high-level flow that we are concerned with, that of
479
+ login causing the selected tab to switch to activity, without having to worry about what is
480
+ happening inside the login feature. To do this, we can turn off `` TestStore/exhaustivity `` in the
481
+ test store, and then just assert on what we are interested in:
482
+
483
+ ``` swift
484
+ let store = TestStore (
485
+ initialState : App.State (),
486
+ reducer : App ()
487
+ )
488
+ store.exhaustivity = .off // ⬅️
489
+
490
+ await store.send (.login (.submitButtonTapped ))
491
+ await store.receive (.login (.delegate (.didLogin ))) {
492
+ $0 .selectedTab = .activity
493
+ }
494
+ ```
495
+
496
+ In particular, we did not assert on how the login's state changed or how the login's effects fed
497
+ data back into the system. We just assert that when the "Submit" button is tapped that eventually
498
+ we get the ` didLogin ` delegate action and that causes the selected tab to flip to activity. Now
499
+ the login feature is free to make any change it wants to make without affecting this integration
500
+ test.
501
+
502
+ Using `` Exhaustivity/off `` for `` TestStore/exhaustivity `` causes all un-asserted changes to pass
503
+ without any notification. If you would like to see what test failures are being suppressed without
504
+ actually causing a failure, you can use `` Exhaustivity/off(showSkippedAssertions:) `` :
505
+
506
+ ``` swift
507
+ let store = TestStore (
508
+ initialState : App.State (),
509
+ reducer : App ()
510
+ )
511
+ store.exhaustivity = .off (showSkippedAssertions : true ) // ⬅️
512
+
513
+ await store.send (.login (.submitButtonTapped ))
514
+ await store.receive (.login (.delegate (.didLogin ))) {
515
+ $0 .selectedTab = .activity
516
+ }
517
+ ```
518
+
519
+ When this is run you will get grey, informational boxes on each assertion where some change wasn't
520
+ fully asserted on:
521
+
522
+ ```
523
+ ◽️ A state change does not match expectation: …
524
+
525
+ App.State(
526
+ authenticatedTab: .loggedOut(
527
+ Login.State(
528
+ − isLoading: false
529
+ + isLoading: true,
530
+ …
531
+ )
532
+ )
533
+ )
534
+
535
+ (Expected: −, Actual: +)
536
+
537
+ ◽️ Skipped receiving .login(.loginResponse(.success))
538
+
539
+ ◽️ A state change does not match expectation: …
540
+
541
+ App.State(
542
+ − authenticatedTab: .loggedOut(…)
543
+ + authenticatedTab: .loggedIn(
544
+ + Profile.State(…)
545
+ + ),
546
+ …
547
+ )
548
+
549
+ (Expected: −, Actual: +)
550
+ ```
551
+
552
+ The test still passes, and none of these notifications are test failures. They just let you know
553
+ what things you are not explicitly asserting against, and can be useful to see when tracking down
554
+ bugs that happen in production but that aren't currently detected in tests.
555
+
400
556
[ Testing-state-changes ] : #Testing-state-changes
401
557
[ Testing-effects ] : #Testing-effects
402
- [ Designing-dependencies ] : #Designing-dependencies
403
- [ Unimplemented-dependencies ] : #Unimplemented-dependencies
558
+ [ gh-combine-schedulers ] : http://github.com/pointfreeco/combine-schedulers
404
559
[ gh-xctest-dynamic-overlay ] : http://github.com/pointfreeco/xctest-dynamic-overlay
405
560
[ tca-examples ] : https://github.com/pointfreeco/swift-composable-architecture/tree/main/Examples
406
561
[ gh-swift-clocks ] : http://github.com/pointfreeco/swift-clocks
562
+ [ merowing.info ] : https://www.merowing.info
563
+ [ exhaustive-testing-in-tca ] : https://www.merowing.info/exhaustive-testing-in-tca/
564
+ [ Composable-Architecture-at-Scale ] : https://vimeo.com/751173570
565
+ [ Non-exhaustive-testing ] : #Non-exhaustive-testing
0 commit comments