mirror of
https://github.com/pointfreeco/swift-composable-architecture.git
synced 2025-12-20 09:11:33 +01:00
* wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * Silence test warnings * wip * wip * wip * update a bunch of docs * wip * wip * fix * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * Kill integration tests for now * wip * wip * wip * wip * updating docs for @Reducer macro * replaced more Reducer protocols with @Reducer * Fixed some broken docc references * wip * Some @Reducer docs * more docs * convert some old styles to new style * wip * wip * wip * wip * wip * wip * wip * bump * update tutorials to use body * update tutorials to use DML on destination state enum * Add diagnostic * wip * updated a few more tests * wip * wip * Add another gotcha * wip * wip * wip * fixes * wip * wip * wip * wip * wip * fix * wip * remove for now * wip * wip * updated some docs * migration guides * more migration guide * fix ci * fix * soft deprecate all apis using AnyCasePath * wip * Fix * fix tests * swift-format 509 compatibility * wip * wip * Update Sources/ComposableArchitecture/Macros.swift Co-authored-by: Mateusz Bąk <bakmatthew@icloud.com> * wip * wip * update optional state case study * remove initializer * Don't use @State for BasicsView integration demo * fix tests * remove reduce diagnostics for now * diagnose error not warning * Update Sources/ComposableArchitecture/Macros.swift Co-authored-by: Jesse Tipton <jesse@jessetipton.com> * wip * move integration tests to cron * Revert "move integration tests to cron" This reverts commitf9bdf2f04b. * disable flakey tests on CI * wip * wip * Revert "Revert "move integration tests to cron"" This reverts commit66aafa7327. * fix * wip * fix --------- Co-authored-by: Brandon Williams <mbrandonw@hey.com> Co-authored-by: Mateusz Bąk <bakmatthew@icloud.com> Co-authored-by: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Co-authored-by: Jesse Tipton <jesse@jessetipton.com>
886 lines
30 KiB
Markdown
886 lines
30 KiB
Markdown
# Testing
|
||
|
||
Learn how to write comprehensive and exhaustive tests for your features built in the Composable
|
||
Architecture.
|
||
|
||
The testability of features built in the Composable Architecture is the #1 priority of the library.
|
||
It should be possible to test not only how state changes when actions are sent into the store, but
|
||
also how effects are executed and feed data back into the system.
|
||
|
||
* [Testing state changes][Testing-state-changes]
|
||
* [Testing effects][Testing-effects]
|
||
* [Non-exhaustive testing][Non-exhaustive-testing]
|
||
* [Testing gotchas](#Testing-gotchas)
|
||
|
||
## Testing state changes
|
||
|
||
State changes are by far the simplest thing to test in features built with the library. A
|
||
``Reducer``'s first responsibility is to mutate the current state based on the action received into
|
||
the system. To test this we can technically run a piece of mutable state through the reducer and
|
||
then assert on how it changed after, like this:
|
||
|
||
```swift
|
||
@Reducer
|
||
struct Feature {
|
||
struct State: Equatable {
|
||
var count = 0
|
||
}
|
||
enum Action {
|
||
case incrementButtonTapped
|
||
case decrementButtonTapped
|
||
}
|
||
var body: some Reduce<State, Action> {
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .incrementButtonTapped:
|
||
state.count += 1
|
||
return .none
|
||
case .decrementButtonTapped:
|
||
state.count -= 1
|
||
return .none
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func testBasics() {
|
||
let feature = Feature()
|
||
var currentState = Feature.State(count: 0)
|
||
_ = feature.reduce(into: ¤tState, action: .incrementButtonTapped)
|
||
XCTAssertEqual(
|
||
currentState,
|
||
State(count: 1)
|
||
)
|
||
|
||
_ = feature.reduce(into: ¤tState, action: .decrementButtonTapped)
|
||
XCTAssertEqual(
|
||
currentState,
|
||
State(count: 0)
|
||
)
|
||
}
|
||
```
|
||
|
||
This will technically work, but it's a lot boilerplate for something that should be quite simple.
|
||
|
||
The library comes with a tool specifically designed to make testing like this much simpler and more
|
||
concise. It's called ``TestStore``, and it is constructed similarly to ``Store`` by providing the
|
||
initial state of the feature and the ``Reducer`` that runs the feature's logic:
|
||
|
||
```swift
|
||
@MainActor
|
||
class CounterTests: XCTestCase {
|
||
func testBasics() async {
|
||
let store = TestStore(initialState: Feature.State(count: 0)) {
|
||
Feature()
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
> Tip: Test cases that use ``TestStore`` should be annotated as `@MainActor` and test methods should
|
||
be marked as `async` since most assertion helpers on ``TestStore`` can suspend.
|
||
|
||
Test stores have a ``TestStore/send(_:assert:file:line:)`` method, but it behaves differently from
|
||
stores and view stores. You provide an action to send into the system, but then you must also
|
||
provide a trailing closure to describe how the state of the feature changed after sending the
|
||
action:
|
||
|
||
```swift
|
||
await store.send(.incrementButtonTapped) {
|
||
// ...
|
||
}
|
||
```
|
||
|
||
This closure is handed a mutable variable that represents the state of the feature _before_ sending
|
||
the action, and it is your job to make the appropriate mutations to it to get it into the shape
|
||
it should be after sending the action:
|
||
|
||
```swift
|
||
await store.send(.incrementButtonTapped) {
|
||
$0.count = 1
|
||
}
|
||
```
|
||
|
||
> The ``TestStore/send(_:assert:file:line:)`` method is `async` for technical reasons that we do not
|
||
> have to worry about right now.
|
||
|
||
If your mutation is incorrect, meaning you perform a mutation that is different from what happened
|
||
in the ``Reducer``, then you will get a test failure with a nicely formatted message showing exactly
|
||
what part of the state does not match:
|
||
|
||
```swift
|
||
await store.send(.incrementButtonTapped) {
|
||
$0.count = 999
|
||
}
|
||
```
|
||
|
||
> ❌ Failure: A state change does not match expectation: …
|
||
>
|
||
> ```diff
|
||
> - TestStoreTests.State(count: 999)
|
||
> + TestStoreTests.State(count: 1)
|
||
> ```
|
||
>
|
||
> (Expected: −, Actual: +)
|
||
|
||
You can also send multiple actions to emulate a script of user actions and assert each step of the
|
||
way how the state evolved:
|
||
|
||
```swift
|
||
await store.send(.incrementButtonTapped) {
|
||
$0.count = 1
|
||
}
|
||
await store.send(.incrementButtonTapped) {
|
||
$0.count = 2
|
||
}
|
||
await store.send(.decrementButtonTapped) {
|
||
$0.count = 1
|
||
}
|
||
```
|
||
|
||
> Tip: Technically we could have written the mutation block in the following manner:
|
||
>
|
||
> ```swift
|
||
> await store.send(.incrementButtonTapped) {
|
||
> $0.count += 1
|
||
> }
|
||
> await store.send(.decrementButtonTapped) {
|
||
> $0.count -= 1
|
||
> }
|
||
> ```
|
||
>
|
||
> …and the test would have still passed.
|
||
>
|
||
> However, this does not produce as strong of an assertion. It shows that the count did increment
|
||
> by one, but we haven't proven we know the precise value of `count` at each step of the way.
|
||
>
|
||
> In general, the less logic you have in the trailing closure of
|
||
> ``TestStore/send(_:assert:file:line:)``, the stronger your assertion will be. It is best to
|
||
> use simple, hard-coded data for the mutation.
|
||
|
||
Test stores do expose a ``TestStore/state`` property, which can be useful for performing assertions
|
||
on computed properties you might have defined on your state. For example, if `State` had a
|
||
computed property for checking if `count` was prime, we could test it like so:
|
||
|
||
```swift
|
||
store.send(.incrementButtonTapped) {
|
||
$0.count = 3
|
||
}
|
||
XCTAssertTrue(store.state.isPrime)
|
||
```
|
||
|
||
However, when inside the trailing closure of ``TestStore/send(_:assert:file:line:)``, the
|
||
``TestStore/state`` property is equal to the state _before_ sending the action, not after. That
|
||
prevents you from being able to use an escape hatch to get around needing to actually describe the
|
||
state mutation, like so:
|
||
|
||
```swift
|
||
store.send(.incrementButtonTapped) {
|
||
$0 = store.state // ❌ store.state is the previous, not current, state.
|
||
}
|
||
```
|
||
|
||
## Testing effects
|
||
|
||
Testing state mutations as shown in the previous section is powerful, but is only half the story
|
||
when it comes to testing features built in the Composable Architecture. The second responsibility of
|
||
``Reducer``s, after mutating state from an action, is to return an ``Effect`` that encapsulates a
|
||
unit of work that runs in the outside world and feeds data back into the system.
|
||
|
||
Effects form a major part of a feature's logic. They can perform network requests to external
|
||
services, load and save data to disk, start and stop timers, interact with Apple frameworks (Core
|
||
Location, Core Motion, Speech Recognition, etc.), and more.
|
||
|
||
As a simple example, suppose we have a feature with a button such that when you tap it, it starts
|
||
a timer that counts up until you reach 5, and then stops. This can be accomplished using the
|
||
``Effect/run(priority:operation:catch:fileID:line:)`` helper on ``Effect``, which provides you with
|
||
an asynchronous context to operate in and can send multiple actions back into the system:
|
||
|
||
```swift
|
||
@Reducer
|
||
struct Feature {
|
||
struct State: Equatable {
|
||
var count = 0
|
||
}
|
||
enum Action {
|
||
case startTimerButtonTapped
|
||
case timerTick
|
||
}
|
||
var body: some Reducer<State, Action> {
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .startTimerButtonTapped:
|
||
state.count = 0
|
||
return .run { send in
|
||
for _ in 1...5 {
|
||
try await Task.sleep(for: .seconds(1))
|
||
await send(.timerTick)
|
||
}
|
||
}
|
||
|
||
case .timerTick:
|
||
state.count += 1
|
||
return .none
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
To test this we can start off similar to how we did in the [previous section][Testing-state-changes]
|
||
when testing state mutations:
|
||
|
||
```swift
|
||
@MainActor
|
||
class TimerTests: XCTestCase {
|
||
func testBasics() async {
|
||
let store = TestStore(initialState: Feature.State(count: 0)) {
|
||
Feature()
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
With the basics set up, we can send an action into the system to assert on what happens, such as the
|
||
`.startTimerButtonTapped` action. This time we don't actually expect state to change at first
|
||
because when starting the timer we don't change state, and so in this case we can leave off the
|
||
trailer closure:
|
||
|
||
```swift
|
||
await store.send(.startTimerButtonTapped)
|
||
```
|
||
|
||
However, if we run the test as-is with no further interactions with the test store, we get a
|
||
failure:
|
||
|
||
> ❌ Failure: An effect returned for this action is still running. It must complete before the end
|
||
> of the test. …
|
||
|
||
This is happening because ``TestStore`` requires you to exhaustively prove how the entire system
|
||
of your feature evolves over time. If an effect is still running when the test finishes and the
|
||
test store did _not_ fail then it could be hiding potential bugs. Perhaps the effect is not
|
||
supposed to be running, or perhaps the data it feeds into the system later is wrong. The test store
|
||
requires all effects to finish.
|
||
|
||
To get this test passing we need to assert on the actions that are sent back into the system
|
||
by the effect. We do this by using the ``TestStore/receive(_:timeout:assert:file:line:)-6325h``
|
||
method, which allows you to assert which action you expect to receive from an effect, as well as how
|
||
the state changes after receiving that effect:
|
||
|
||
```swift
|
||
await store.receive(\.timerTick) {
|
||
$0.count = 1
|
||
}
|
||
```
|
||
|
||
> Note: We are using key path syntax `\.timerTick` to specify the case of the action we expect to
|
||
receive. This works because the ``ComposableArchitecture/Reducer()`` macro automatically applies the
|
||
`@CasePathable` macro to the `Action` enum, and `@CasePathable` comes from our
|
||
[CasePaths][swift-case-paths] library which brings key path syntax to enum cases.
|
||
|
||
However, if we run this test we still get a failure because we asserted a `timerTick` action was
|
||
going to be received, but after waiting around for a small amount of time no action was received:
|
||
|
||
> ❌ Failure: Expected to receive an action, but received none after 0.1 seconds.
|
||
|
||
This is because our timer is on a 1 second interval, and by default
|
||
``TestStore/receive(_:timeout:assert:file:line:)-6325h`` only waits for a fraction of a second. This
|
||
is because typically you should not be performing real time-based asynchrony in effects, and instead
|
||
using a controlled entity, such as a clock, that can be sped up in tests. We will demonstrate this
|
||
in a moment, so for now let's increase the timeout:
|
||
|
||
```swift
|
||
await store.receive(\.timerTick, timeout: .seconds(2)) {
|
||
$0.count = 1
|
||
}
|
||
```
|
||
|
||
This assertion now passes, but the overall test is still failing because there are still more
|
||
actions to receive. The timer should tick 5 times in total, so we need five `receive` assertions:
|
||
|
||
```swift
|
||
await store.receive(\.timerTick, timeout: .seconds(2)) {
|
||
$0.count = 1
|
||
}
|
||
await store.receive(\.timerTick, timeout: .seconds(2)) {
|
||
$0.count = 2
|
||
}
|
||
await store.receive(\.timerTick, timeout: .seconds(2)) {
|
||
$0.count = 3
|
||
}
|
||
await store.receive(\.timerTick, timeout: .seconds(2)) {
|
||
$0.count = 4
|
||
}
|
||
await store.receive(\.timerTick, timeout: .seconds(2)) {
|
||
$0.count = 5
|
||
}
|
||
```
|
||
|
||
Now the full test suite passes, and we have exhaustively proven how effects are executed in this
|
||
feature. If in the future we tweak the logic of the effect, like say have it emit 10 times instead
|
||
of 5, then we will immediately get a test failure letting us know that we have not properly
|
||
asserted on how the features evolve over time.
|
||
|
||
However, there is something not ideal about how this feature is structured, and that is the fact
|
||
that we are doing actual, uncontrolled time-based asynchrony in the effect:
|
||
|
||
```swift
|
||
return .run { send in
|
||
for _ in 1...5 {
|
||
try await Task.sleep(for: .seconds(1)) // ⬅️
|
||
await send(.timerTick)
|
||
}
|
||
}
|
||
```
|
||
|
||
This means for our test to run we must actually wait for 5 real world seconds to pass so that we
|
||
can receive all of the actions from the timer. This makes our test suite far too slow. What if in
|
||
the future we need to test a feature that has a timer that emits hundreds or thousands of times?
|
||
We cannot hold up our test suite for minutes or hours just to test that one feature.
|
||
|
||
To fix this we need to add a dependency to the reducer that aids in performing time-based
|
||
asynchrony, but in a way that is controllable. One way to do this is to add a clock as a
|
||
`@Dependency` to the reducer:
|
||
|
||
```swift
|
||
import Clocks
|
||
|
||
@Reducer
|
||
struct Feature {
|
||
struct State { /* ... */ }
|
||
enum Action { /* ... */ }
|
||
@Dependency(\.continuousClock) var clock
|
||
// ...
|
||
}
|
||
```
|
||
|
||
> Tip: To make use of controllable clocks you must use the [Clocks][gh-swift-clocks] library, which
|
||
> is automatically included with the Composable Architecture.
|
||
|
||
And then the timer effect in the reducer can make use of the clock to sleep rather than reaching
|
||
out to the uncontrollable `Task.sleep` method:
|
||
|
||
```swift
|
||
return .run { send in
|
||
for _ in 1...5 {
|
||
try await self.clock.sleep(for: .seconds(1))
|
||
await send(.timerTick)
|
||
}
|
||
}
|
||
```
|
||
|
||
> Tip: The `sleep(for:)` method on `Clock` is provided by the [Swift Clocks][gh-swift-clocks]
|
||
> library.
|
||
|
||
By having a clock as a dependency in the feature we can supply a controlled version in tests, such
|
||
as an immediate clock that does not suspend at all when you ask it to sleep:
|
||
|
||
```swift
|
||
let store = TestStore(initialState: Feature.State(count: 0)) {
|
||
Feature()
|
||
} withDependencies: {
|
||
$0.continuousClock = ImmediateClock()
|
||
}
|
||
```
|
||
|
||
With that small change we can drop the `timeout` arguments from the
|
||
``TestStore/receive(_:timeout:assert:file:line:)-6325h`` invocations:
|
||
|
||
```swift
|
||
await store.receive(\.timerTick) {
|
||
$0.count = 1
|
||
}
|
||
await store.receive(\.timerTick) {
|
||
$0.count = 2
|
||
}
|
||
await store.receive(\.timerTick) {
|
||
$0.count = 3
|
||
}
|
||
await store.receive(\.timerTick) {
|
||
$0.count = 4
|
||
}
|
||
await store.receive(\.timerTick) {
|
||
$0.count = 5
|
||
}
|
||
```
|
||
|
||
…and the test still passes, but now does so immediately.
|
||
|
||
The more time you take to control the dependencies your features use, the easier it will be to
|
||
write tests for your features. To learn more about designing dependencies and how to best leverage
|
||
dependencies, read the <doc:DependencyManagement> article.
|
||
|
||
## Non-exhaustive testing
|
||
|
||
The previous sections describe in detail how to write tests in the Composable Architecture that
|
||
exhaustively prove how the entire feature evolves over time. You must assert on how every piece
|
||
of state changes, how every effect feeds data back into the system, and you must even make sure
|
||
that all effects complete before the test store is deallocated. This can be powerful, but it can
|
||
also be a nuisance, especially for highly composed features. This is why sometimes you may want
|
||
to test in a non-exhaustive style.
|
||
|
||
> Tip: The concept of "non-exhaustive test store" was first introduced by
|
||
> [Krzysztof Zabłocki][merowing.info] in a [blog post][exhaustive-testing-in-tca] and
|
||
> [conference talk][Composable-Architecture-at-Scale], and then later became integrated into the
|
||
> core library.
|
||
|
||
This style of testing is most useful for testing the integration of multiple features where you want
|
||
to focus on just a certain slice of the behavior. Exhaustive testing can still be important to use
|
||
for leaf node features, where you truly do want to assert on everything happening inside the
|
||
feature.
|
||
|
||
For example, suppose you have a tab-based application where the 3rd tab is a login screen. The user
|
||
can fill in some data on the screen, then tap the "Submit" button, and then a series of events
|
||
happens to log the user in. Once the user is logged in, the 3rd tab switches from a login screen
|
||
to a profile screen, _and_ the selected tab switches to the first tab, which is an activity screen.
|
||
|
||
When writing tests for the login feature we will want to do that in the exhaustive style so that we
|
||
can prove exactly how the feature would behave in production. But, suppose we wanted to write an
|
||
integration test that proves after the user taps the "Login" button that ultimately the selected
|
||
tab switches to the first tab.
|
||
|
||
In order to test such a complex flow we must test the integration of multiple features, which means
|
||
dealing with complex, nested state and effects. We can emulate this flow in a test by sending
|
||
actions that mimic the user logging in, and then eventually assert that the selected tab switched
|
||
to activity:
|
||
|
||
```swift
|
||
let store = TestStore(initialState: AppFeature.State()) {
|
||
AppFeature()
|
||
}
|
||
|
||
// 1️⃣ Emulate user tapping on submit button.
|
||
await store.send(.login(.submitButtonTapped)) {
|
||
// 2️⃣ Assert how all state changes in the login feature
|
||
$0.login?.isLoading = true
|
||
// ...
|
||
}
|
||
|
||
// 3️⃣ Login feature performs API request to login, and
|
||
// sends response back into system.
|
||
await store.receive(\.login.loginResponse.success) {
|
||
// 4️⃣ Assert how all state changes in the login feature
|
||
$0.login?.isLoading = false
|
||
// ...
|
||
}
|
||
|
||
// 5️⃣ Login feature sends a delegate action to let parent
|
||
// feature know it has successfully logged in.
|
||
await store.receive(\.login.delegate.didLogin) {
|
||
// 6️⃣ Assert how all of app state changes due to that action.
|
||
$0.authenticatedTab = .loggedIn(
|
||
Profile.State(...)
|
||
)
|
||
// ...
|
||
// 7️⃣ *Finally* assert that the selected tab switches to activity.
|
||
$0.selectedTab = .activity
|
||
}
|
||
```
|
||
|
||
Doing this with exhaustive testing is verbose, and there are a few problems with this:
|
||
|
||
* We need to be intimately knowledgeable in how the login feature works so that we can assert
|
||
on how its state changes and how its effects feed data back into the system.
|
||
* If the login feature were to change its logic we may get test failures here even though the
|
||
logic we are actually trying to test doesn't really care about those changes.
|
||
* This test is very long, and so if there are other similar but slightly different flows we want
|
||
to test we will be tempted to copy-and-paste the whole thing, leading to lots of duplicated,
|
||
fragile tests.
|
||
|
||
Non-exhaustive testing allows us to test the high-level flow that we are concerned with, that of
|
||
login causing the selected tab to switch to activity, without having to worry about what is
|
||
happening inside the login feature. To do this, we can turn off ``TestStore/exhaustivity`` in the
|
||
test store, and then just assert on what we are interested in:
|
||
|
||
```swift
|
||
let store = TestStore(initialState: AppFeature.State()) {
|
||
AppFeature()
|
||
}
|
||
store.exhaustivity = .off // ⬅️
|
||
|
||
await store.send(.login(.submitButtonTapped))
|
||
await store.receive(\.login.delegate.didLogin) {
|
||
$0.selectedTab = .activity
|
||
}
|
||
```
|
||
|
||
In particular, we did not assert on how the login's state changed or how the login's effects fed
|
||
data back into the system. We just assert that when the "Submit" button is tapped that eventually
|
||
we get the `didLogin` delegate action and that causes the selected tab to flip to activity. Now
|
||
the login feature is free to make any change it wants to make without affecting this integration
|
||
test.
|
||
|
||
Using ``Exhaustivity/off`` for ``TestStore/exhaustivity`` causes all un-asserted changes to pass
|
||
without any notification. If you would like to see what test failures are being suppressed without
|
||
actually causing a failure, you can use ``Exhaustivity/off(showSkippedAssertions:)``:
|
||
|
||
```swift
|
||
let store = TestStore(initialState: AppFeature.State()) {
|
||
AppFeature()
|
||
}
|
||
store.exhaustivity = .off(showSkippedAssertions: true) // ⬅️
|
||
|
||
await store.send(.login(.submitButtonTapped))
|
||
await store.receive(\.login.delegate.didLogin) {
|
||
$0.selectedTab = .activity
|
||
}
|
||
```
|
||
|
||
When this is run you will get grey, informational boxes on each assertion where some change wasn't
|
||
fully asserted on:
|
||
|
||
> ◽️ Expected failure: A state change does not match expectation: …
|
||
>
|
||
> ```diff
|
||
> AppFeature.State(
|
||
> authenticatedTab: .loggedOut(
|
||
> Login.State(
|
||
> - isLoading: false
|
||
> + isLoading: true,
|
||
> …
|
||
> )
|
||
> )
|
||
> )
|
||
> ```
|
||
>
|
||
> Skipped receiving .login(.loginResponse(.success))
|
||
>
|
||
> A state change does not match expectation: …
|
||
>
|
||
> ```diff
|
||
> AppFeature.State(
|
||
> - authenticatedTab: .loggedOut(…)
|
||
> + authenticatedTab: .loggedIn(
|
||
> + Profile.State(…)
|
||
> + ),
|
||
> …
|
||
> )
|
||
> ```
|
||
>
|
||
> (Expected: −, Actual: +)
|
||
|
||
The test still passes, and none of these notifications are test failures. They just let you know
|
||
what things you are not explicitly asserting against, and can be useful to see when tracking down
|
||
bugs that happen in production but that aren't currently detected in tests.
|
||
|
||
#### Understanding non-exhaustive testing
|
||
|
||
It can be important to understand how non-exhaustive testing works under the hood because it does
|
||
limit the ways in which you can assert on state changes.
|
||
|
||
When you construct an _exhaustive_ test store, which is the default, the `$0` used inside the
|
||
trailing closure of ``TestStore/send(_:assert:file:line:)`` represents the state _before_ the
|
||
action is sent:
|
||
|
||
```swift
|
||
let store = TestStore(/* ... */)
|
||
// ℹ️ "on" is the default so technically this is not needed
|
||
store.exhaustivity = .on
|
||
|
||
store.send(.buttonTapped) {
|
||
$0 // Represents the state *before* the action was sent
|
||
}
|
||
```
|
||
|
||
This forces you to apply any mutations necessary to `$0` to match the state _after_ the action
|
||
is sent.
|
||
|
||
Non-exhaustive test stores flip this on its head. In such a test store, the `$0` handed to the
|
||
trailing closure of `send` represents the state _after_ the action was sent:
|
||
|
||
```swift
|
||
let store = TestStore(/* ... */)
|
||
store.exhaustivity = .off
|
||
|
||
store.send(.buttonTapped) {
|
||
$0 // Represents the state *after* the action was sent
|
||
}
|
||
```
|
||
|
||
This means you don't have to make any mutations to `$0` at all and the assertion will already pass.
|
||
But, if you do make a mutation, then it must match what is already in the state, thus allowing you
|
||
to assert on only the state changes you are interested in.
|
||
|
||
However, this difference between how ``TestStore`` behaves when run in exhaustive mode versus
|
||
non-exhaustive mode does restrict the kinds of mutations you can make inside the trailing closure of
|
||
`send`. For example, suppose you had an action in your feature that removes the last element of a
|
||
collection:
|
||
|
||
```swift
|
||
case .removeButtonTapped:
|
||
state.values.removeLast()
|
||
return .none
|
||
```
|
||
|
||
To test this in an exhaustive store it is completely fine to do this:
|
||
|
||
```swift
|
||
await store.send(.removeButtonTapped) {
|
||
$0.values.removeLast()
|
||
}
|
||
```
|
||
|
||
This works because `$0` is the state before the action is sent, and so we can remove the last
|
||
element to prove that the reducer does the same work.
|
||
|
||
However, in a non-exhaustive store this will not work:
|
||
|
||
```swift
|
||
store.exhaustivity = .off
|
||
await store.send(.removeButtonTapped) {
|
||
$0.values.removeLast() // ❌
|
||
}
|
||
```
|
||
|
||
This will either fail, or possibly even crash the test suite. This is because in a non-exhaustive
|
||
test store, `$0` in the trailing closure of `send` represents the state _after_ the action has been
|
||
sent, and so the last element has already been removed. By executing `$0.values.removeLast()` we are
|
||
just removing an additional element from the end.
|
||
|
||
So, for non-exhaustive test stores you cannot use "relative" mutations for assertions. That is, you
|
||
cannot mutate via methods like `removeLast`, `append`, and anything that incrementally applies a
|
||
mutation. Instead you must perform an "absolute" mutation, where you fully replace the collection
|
||
with its final value:
|
||
|
||
```swift
|
||
store.exhaustivity = .off
|
||
await store.send(.removeButtonTapped) {
|
||
$0.values = []
|
||
}
|
||
```
|
||
|
||
Or you can weaken the assertion by asserting only on the count of its elements rather than the
|
||
content of the element:
|
||
|
||
```swift
|
||
store.exhaustivity = .off
|
||
await store.send(.removeButtonTapped) {
|
||
XCTAssertEqual($0.values.count, 0)
|
||
}
|
||
```
|
||
|
||
Further, when using non-exhaustive test stores that also show skipped assertions (via
|
||
``Exhaustivity/off(showSkippedAssertions:)``), then there is another caveat to keep in mind. In
|
||
such test stores, the trailing closure of ``TestStore/send(_:assert:file:line:)`` is invoked _twice_
|
||
by the test store. First with `$0` representing the state after the action is sent to see if it does
|
||
not match the true state, and then again with `$0` representing the state before the action is sent
|
||
so that we can show what state assertions were skipped.
|
||
|
||
Because the test store can invoke your trailing assertion closure twice you must be careful if your
|
||
closure performs any side effects, because those effects will be executed twice. For example,
|
||
suppose you have a domain model that uses the controllable `@Dependency(\.uuid)` to generate a UUID:
|
||
|
||
```swift
|
||
struct Model: Equatable {
|
||
let id: UUID
|
||
init() {
|
||
@Dependency(\.uuid) var uuid
|
||
self.id = uuid()
|
||
}
|
||
}
|
||
```
|
||
|
||
This is a perfectly fine to pattern to adopt in the Composable Architecture, but it does cause
|
||
trouble when using non-exhaustive test stores and showing skipped assertions. To see this, consider
|
||
the following simple reducer that appends a new model to an array when an action is sent:
|
||
|
||
```swift
|
||
@Reducer
|
||
struct Feature {
|
||
struct State: Equatable {
|
||
var values: [Model] = []
|
||
}
|
||
enum Action {
|
||
case addButtonTapped
|
||
}
|
||
var body: some Reducer<State, Action> {
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .addButtonTapped:
|
||
state.values.append(Model())
|
||
return .none
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
We'd like to be able to write a test for this by asserting that when the `addButtonTapped` action
|
||
is sent a model is append to the `values` array:
|
||
|
||
```swift
|
||
func testAdd() async {
|
||
let store = TestStore(initialState: Feature.State()) {
|
||
Feature()
|
||
} withDependencies: {
|
||
$0.uuid = .incrementing
|
||
}
|
||
store.exhaustivity = .off(showSkippedAssertions: true)
|
||
|
||
await store.send(.addButtonTapped) {
|
||
$0.values = [Model()]
|
||
}
|
||
}
|
||
```
|
||
|
||
While we would expect this simple test to pass, it fails when `showSkippedAssertions` is set to
|
||
`true`:
|
||
|
||
> ❌ Failure: A state change does not match expectation: …
|
||
>
|
||
> ```diff
|
||
> TestStoreNonExhaustiveTests.Feature.State(
|
||
> values: [
|
||
> [0]: TestStoreNonExhaustiveTests.Model(
|
||
> - id: UUID(00000000-0000-0000-0000-000000000001)
|
||
> + id: UUID(00000000-0000-0000-0000-000000000000)
|
||
> )
|
||
> ]
|
||
> )
|
||
> ```
|
||
>
|
||
> (Expected: −, Actual: +)
|
||
|
||
This is happening because the trailing closure is invoked twice, and the side effect that is
|
||
executed when the closure is first invoked is bleeding over into when it is invoked a second time.
|
||
|
||
In particular, when the closure is evaluated the first time it causes `Model()` to be constructed,
|
||
which secretly generates the next auto-incrementing UUID. Then, when we run the closure again
|
||
_another_ `Model()` is constructed, which causes another auto-incrementing UUID to be generated,
|
||
and that value does not match our expectations.
|
||
|
||
If you want to use the `showSkippedAssertions` option for
|
||
``Exhaustivity/off(showSkippedAssertions:)`` then you should avoid performing any kind of side
|
||
effect in `send`, including using `@Dependency` directly in your models' initializers. Instead
|
||
force those values to be provided at the moment of initializing the model:
|
||
|
||
```swift
|
||
struct Model: Equatable {
|
||
let id: UUID
|
||
init(id: UUID) {
|
||
self.id = id
|
||
}
|
||
}
|
||
```
|
||
|
||
And then move the responsibility of generating new IDs to the reducer:
|
||
|
||
```swift
|
||
@Reducer
|
||
struct Feature {
|
||
// ...
|
||
@Dependency(\.uuid) var uuid
|
||
var body: some Reducer<State, Action> {
|
||
Reduce { state, action in
|
||
switch action {
|
||
case .addButtonTapped:
|
||
state.values.append(Model(id: self.uuid()))
|
||
return .none
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
And now you can write the test more simply by providing the ID explicitly:
|
||
|
||
```swift
|
||
await store.send(.addButtonTapped) {
|
||
$0.values = [
|
||
Model(id: UUID(0))
|
||
]
|
||
}
|
||
```
|
||
|
||
And it works if you send the action multiple times:
|
||
|
||
```swift
|
||
await store.send(.addButtonTapped) {
|
||
$0.values = [
|
||
Model(id: UUID(0))
|
||
]
|
||
}
|
||
await store.send(.addButtonTapped) {
|
||
$0.values = [
|
||
Model(id: UUID(0)),
|
||
Model(id: UUID(1))
|
||
]
|
||
}
|
||
```
|
||
|
||
## Testing gotchas
|
||
|
||
### Testing host application
|
||
|
||
This is not well known, but when an application target runs tests it actually boots up a simulator
|
||
and runs your actual application entry point in the simulator. This means while tests are running,
|
||
your application's code is separately also running. This can be a huge gotcha because it means you
|
||
may be unknowingly making network requests, tracking analytics, writing data to user defaults or to
|
||
the disk, and more.
|
||
|
||
This usually flies under the radar and you just won't know it's happening, which can be problematic.
|
||
But, once you start using this library and start controlling your dependencies, the problem can
|
||
surface in a very visible manner. Typically, when a dependency is used in a test context without
|
||
being overridden, a test failure occurs. This makes it possible for your test to pass successfully,
|
||
yet for some mysterious reason the test suite fails. This happens because the code in the _app
|
||
host_ is now running in a test context, and accessing dependencies will cause test failures.
|
||
|
||
This only happens when running tests in a _application target_, that is, a target that is
|
||
specifically used to launch the application for a simulator or device. This does not happen when
|
||
running tests for frameworks or SPM libraries, which is yet another good reason to modularize
|
||
your code base.
|
||
|
||
However, if you aren't in a position to modularize your code base right now, there is a quick
|
||
fix. Our [XCTest Dynamic Overlay][xctest-dynamic-overlay-gh] library, which is transitively included
|
||
with this library, comes with a property you can check to see if tests are currently running. If
|
||
they are, you can omit the entire entry point of your application:
|
||
|
||
```swift
|
||
import SwiftUI
|
||
import XCTestDynamicOverlay
|
||
|
||
@main
|
||
struct MyApp: App {
|
||
var body: some Scene {
|
||
WindowGroup {
|
||
if !_XCTIsTesting {
|
||
// Your real root view
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
That will allow tests to run in the application target without your actual application code
|
||
interfering.
|
||
|
||
### Statically linking your tests target to ComposableArchitecture
|
||
|
||
If you statically link the `ComposableArchitecture` module to your tests target, its implementation
|
||
may clash with the implementation that is statically linked to the app itself. The most usually
|
||
manifests by getting mysterious test failures telling you that you are using live dependencies in
|
||
your tests even though you have overridden your dependencies.
|
||
|
||
In such cases Xcode will display multiple warnings in the console similar to:
|
||
|
||
> Class _TtC12Dependencies[…] is implemented in both […] and […].
|
||
> One of the two will be used. Which one is undefined.
|
||
|
||
The solution is to remove the static link to `ComposableArchitecture` from your test target, as you
|
||
transitively get access to it through the app itself. In Xcode, go to "Build Phases" and remove
|
||
"ComposableArchitecture" from the "Link Binary With Libraries" section. When using SwiftPM, remove
|
||
the "ComposableArchitecture" entry from the `testTarget`'s' `dependencies` array in `Package.swift`.
|
||
|
||
[xctest-dynamic-overlay-gh]: http://github.com/pointfreeco/xctest-dynamic-overlay
|
||
[Testing-state-changes]: #Testing-state-changes
|
||
[Testing-effects]: #Testing-effects
|
||
[gh-combine-schedulers]: http://github.com/pointfreeco/combine-schedulers
|
||
[gh-xctest-dynamic-overlay]: http://github.com/pointfreeco/xctest-dynamic-overlay
|
||
[tca-examples]: https://github.com/pointfreeco/swift-composable-architecture/tree/main/Examples
|
||
[gh-swift-clocks]: http://github.com/pointfreeco/swift-clocks
|
||
[merowing.info]: https://www.merowing.info
|
||
[exhaustive-testing-in-tca]: https://www.merowing.info/exhaustive-testing-in-tca/
|
||
[Composable-Architecture-at-Scale]: https://vimeo.com/751173570
|
||
[Non-exhaustive-testing]: #Non-exhaustive-testing
|
||
[swift-case-paths]: http://github.com/pointfreeco/swift-case-paths
|