Skip to content

Commit 3bea194

Browse files
stephencelisp4checo
authored andcommitted
Non-exhaustive testing (#1599)
* Allow for non-exhaustive test store. * wip * wip * wip * wip * wip * clean up * docs * wip * wip * wip * wip * wip * wip * wip * wip * wip * fix tests * wip * wip * wip * wip * wip * merge fixes * fixes * wip * wip * wip * infrastructure * add extraction based overload * wip * more tests * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * docs fix * fix * wip * wip * wip * lots of doc fixes * wip * ci * wip * wip * wip; * wip * Update .github/workflows/ci.yml Co-authored-by: Brandon Williams <[email protected]> * Update Sources/ComposableArchitecture/Documentation.docc/Articles/Testing.md Co-authored-by: Brandon Williams <[email protected]> * fix docs Co-authored-by: Brandon Williams <[email protected]> Co-authored-by: Brandon Williams <[email protected]> (cherry picked from commit 5d687b6b41b5c128f92d80d42171a9767e6dbb80) # Conflicts: # .github/workflows/ci.yml # ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved # Sources/ComposableArchitecture/Documentation.docc/Articles/Testing.md # Sources/ComposableArchitecture/TestStore.swift
1 parent d9097fd commit 3bea194

File tree

15 files changed

+2311
-216
lines changed

15 files changed

+2311
-216
lines changed

Examples/VoiceMemos/VoiceMemos/Dependencies.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@ extension DependencyValues {
2828

2929
private enum TemporaryDirectoryKey: DependencyKey {
3030
static let liveValue: @Sendable () -> URL = { URL(fileURLWithPath: NSTemporaryDirectory()) }
31-
static let testValue: @Sendable () -> URL = unimplemented(
32-
#"@Dependency(\.temporaryDirectory)"#
31+
static let testValue: @Sendable () -> URL = XCTUnimplemented(
32+
#"@Dependency(\.temporaryDirectory)"#,
33+
placeholder: URL(fileURLWithPath: NSTemporaryDirectory())
3334
)
3435
}
3536
}

Examples/VoiceMemos/VoiceMemosTests/VoiceMemosTests.swift

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@ final class VoiceMemosTests: XCTestCase {
113113
store.dependencies.audioRecorder.startRecording = { _ in
114114
try await didFinish.stream.first { _ in true }!
115115
}
116-
store.dependencies.date = .constant(Date(timeIntervalSinceReferenceDate: 0))
117116
store.dependencies.continuousClock = self.clock
117+
store.dependencies.date = .constant(Date(timeIntervalSinceReferenceDate: 0))
118118
store.dependencies.temporaryDirectory = { URL(fileURLWithPath: "/tmp") }
119119
store.dependencies.uuid = .constant(UUID(uuidString: "DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF")!)
120120

@@ -140,6 +140,37 @@ final class VoiceMemosTests: XCTestCase {
140140
await recordingMemoTask.cancel()
141141
}
142142

143+
// Demonstration of how to write a non-exhaustive test for recording a memo and it failing to
144+
// record.
145+
func testRecordMemoFailure_NonExhaustive() async {
146+
let store = TestStore(
147+
initialState: VoiceMemos.State(),
148+
reducer: VoiceMemos()
149+
)
150+
store.exhaustivity = .off(showSkippedAssertions: true)
151+
152+
struct SomeError: Error, Equatable {}
153+
let didFinish = AsyncThrowingStream<Bool, Error>.streamWithContinuation()
154+
155+
store.dependencies.audioRecorder.currentTime = { 2.5 }
156+
store.dependencies.audioRecorder.requestRecordPermission = { true }
157+
store.dependencies.audioRecorder.startRecording = { _ in
158+
try await didFinish.stream.first { _ in true }!
159+
}
160+
store.dependencies.continuousClock = self.clock
161+
store.dependencies.date = .constant(Date(timeIntervalSinceReferenceDate: 0))
162+
store.dependencies.temporaryDirectory = { URL(fileURLWithPath: "/tmp") }
163+
store.dependencies.uuid = .constant(UUID(uuidString: "DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF")!)
164+
165+
await store.send(.recordButtonTapped)
166+
await store.send(.recordingMemo(.task))
167+
didFinish.continuation.finish(throwing: SomeError())
168+
await store.receive(.recordingMemo(.delegate(.didFinish(.failure(SomeError()))))) {
169+
$0.alert = AlertState(title: TextState("Voice memo recording failed."))
170+
$0.recordingMemo = nil
171+
}
172+
}
173+
143174
func testPlayMemoHappyPath() async {
144175
let url = URL(fileURLWithPath: "pointfreeco/functions.m4a")
145176
let store = TestStore(

Sources/ComposableArchitecture/Documentation.docc/Articles/SwiftConcurrency.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,9 @@ extension DependencyValues {
8989
If `FactClient` is not `Sendable`, for whatever reason, you will get a warning in the `get`
9090
and `set` lines:
9191

92-
>⚠️ Type 'FactClient' does not conform to the 'Sendable' protocol
92+
```
93+
⚠️ Type 'FactClient' does not conform to the 'Sendable' protocol
94+
```
9395

9496
To fix this you need to make each dependency `Sendable`. This usually just means making sure
9597
that the interface type only holds onto `Sendable` data, and in particular, any closure-based

Sources/ComposableArchitecture/Documentation.docc/Articles/Testing.md

Lines changed: 184 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,9 @@ The testability of features built in the Composable Architecture is the #1 prior
77
It should be possible to test not only how state changes when actions are sent into the store,
88
but also how effects are executed and feed data back into the system.
99

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]
1413

1514
## Testing state changes
1615

@@ -74,8 +73,8 @@ class CounterTests: XCTestCase {
7473
> Tip: Test cases that use ``TestStore`` should be annotated as `@MainActor` and test methods should
7574
be marked as `async` since most assertion helpers on ``TestStore`` can suspend.
7675

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
7978
provide a trailing closure to describe how the state of the feature changed after sending the
8079
action:
8180

@@ -95,8 +94,8 @@ await store.send(.incrementButtonTapped) {
9594
}
9695
```
9796

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.
10099
101100
If your mutation is incorrect, meaning you perform a mutation that is different from what happened
102101
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) {
138137
> await store.send(.incrementButtonTapped) {
139138
> $0.count += 1
140139
> }
140+
> await store.send(.decrementButtonTapped) {
141+
> $0.count -= 1
142+
> }
141143
> ```
142144
>
143145
> and the test would have still passed.
@@ -146,8 +148,8 @@ await store.send(.decrementButtonTapped) {
146148
> by one, but we haven't proven we know the precise value of `count` at each step of the way.
147149
>
148150
> 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.
151153
152154
Test stores do expose a ``TestStore/state`` property, which can be useful for performing assertions
153155
on computed properties you might have defined on your state. For example, if `State` had a
@@ -160,7 +162,7 @@ store.send(.incrementButtonTapped) {
160162
XCTAssertTrue(store.state.isPrime)
161163
```
162164
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
164166
``TestStore/state`` property is equal to the state _before_ sending the action, not after. That
165167
prevents you from being able to use an escape hatch to get around needing to actually describe the
166168
state mutation, like so:
@@ -184,8 +186,9 @@ Location, Core Motion, Speech Recognition, etc.), and more.
184186

185187
As a simple example, suppose we have a feature with a button such that when you tap it, it starts
186188
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:
189192

190193
```swift
191194
struct Feature: ReducerProtocol {
@@ -246,14 +249,14 @@ failure:
246249

247250
This is happening because ``TestStore`` requires you to exhaustively prove how the entire system
248251
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
250253
supposed to be running, or perhaps the data it feeds into the system later is wrong. The test store
251254
requires all effects to finish.
252255

253256
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:
257260

258261
```swift
259262
await store.receive(.timerTick) {
@@ -269,8 +272,8 @@ going to be received, but after waiting around for a small amount of time no act
269272
```
270273

271274
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
274277
using a controlled entity, such as a clock, that can be sped up in tests. We will demonstrate this
275278
in a moment, so for now let's increase the timeout:
276279

@@ -368,15 +371,12 @@ store.dependencies.continuousClock = ImmediateClock()
368371
```
369372

370373
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:
372375

373376
```swift
374377
await store.receive(.timerTick) {
375378
$0.count = 1
376379
}
377-
await store.receive(.timerTick) {
378-
$0.count = 1
379-
}
380380
await store.receive(.timerTick) {
381381
$0.count = 2
382382
}
@@ -397,10 +397,169 @@ The more time you take to control the dependencies your features use, the easier
397397
write tests for your features. To learn more about designing dependencies and how to best leverage
398398
dependencies, read the <doc:DependencyManagement> article.
399399

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+
400556
[Testing-state-changes]: #Testing-state-changes
401557
[Testing-effects]: #Testing-effects
402-
[Designing-dependencies]: #Designing-dependencies
403-
[Unimplemented-dependencies]: #Unimplemented-dependencies
558+
[gh-combine-schedulers]: http://github.com/pointfreeco/combine-schedulers
404559
[gh-xctest-dynamic-overlay]: http://github.com/pointfreeco/xctest-dynamic-overlay
405560
[tca-examples]: https://github.com/pointfreeco/swift-composable-architecture/tree/main/Examples
406561
[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

Sources/ComposableArchitecture/Documentation.docc/Extensions/CombineReducers.md

Lines changed: 0 additions & 7 deletions
This file was deleted.

Sources/ComposableArchitecture/Documentation.docc/Extensions/Deprecations/TestStoreDeprecations.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,17 @@ Avoid using deprecated APIs in your app. Select a method to see the replacement
1818

1919
### Testing reducers
2020

21-
- ``TestStore/send(_:_:file:line:)-4i91s``
22-
- ``TestStore/receive(_:timeout:_:file:line:)-8huvm``
23-
- ``TestStore/receive(_:_:file:line:)``
24-
- ``TestStore/finish(timeout:file:line:)-43l4y``
21+
- ``TestStore/send(_:assert:file:line:)-30pjj``
22+
- ``TestStore/receive(_:assert:file:line:)-2nhm0``
23+
- ``TestStore/receive(_:assert:file:line:)-6fuav``
24+
- ``TestStore/receive(_:assert:file:line:)-u5tf``
2525
- ``TestStore/assert(_:file:line:)-707lb``
2626
- ``TestStore/assert(_:file:line:)-4gff7``
2727
- ``TestStore/LocalState``
2828
- ``TestStore/LocalAction``
2929
- ``TestStore/Step``
30+
31+
### Methods for skipping tests
32+
33+
- ``TestStore/skipReceivedActions(strict:file:line:)-3nldt``
34+
- ``TestStore/skipInFlightEffects(strict:file:line:)-95n5f``

0 commit comments

Comments
 (0)