Video #84: Testable State Management: Ergonomics
Episode: Video #84 Date: Dec 9, 2019 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep84-testable-state-management-ergonomics

Description
We not only want our architecture to be testable, but we want it to be super easy to write tests, and perhaps even a joy to write tests! Right now there is a bit of ceremony involved in writing tests, so we will show how to hide away those details behind a nice, ergonomic API.
Video
Cloudflare Stream video ID: 18233edca83720aa9d0cbd8d1c91c910 Local file: video_84_testable-state-management-ergonomics.mp4 *(download with --video 84)*
References
- Discussions
- Elm: A delightful language for reliable webapps
- Redux: A predictable state container for JavaScript apps.
- Composable Reducers
- 0084-testable-state-management-ergonomics
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:05
We have now written some truly powerful tests. Not only are we testing how the state of the application evolves as the user does various things in the UI, but we are also performing end-to-end testing on effects by asserting that the right effect executes and the right action is returned.
— 0:27
We do want to mention that the way we have constructed our environments is not 100% ideal right now. It got the job done for this application, but we will run into problems once we want to share a dependency amongst many independent modules, like say our PrimeModal module wanted access to the FileClient . We’d have no choice but to create a new FileClient instance for that module, which would mean the app has two FileClient s floating around. Fortunately, it’s very simple to fix this, and we will be doing that in a future episode really soon.
— 1:03
Another thing that isn’t so great about our tests is that they’re quite unwieldy. Some of the last tests we wrote are over 60 lines! So if we wrote just 10 tests this file would already be over 600 lines.
— 1:22
There is a lot of ceremony in our tests right now. We must:
— 1:25
create expectations run the effects wait for expectations fulfill expectations capture the next action assert what action we got and feed it back into the reducer.
— 1:40
That’s pretty intense to have to repeat for every effect we test, and as we mentioned it doesn’t even catch the full story of effects since some extra ones could have slipped in.
— 1:44
Maybe we can focus on the bare essentials: the shape of what we need to do in order to assert expectations against our architecture. It seems to boil down to providing some initial state, providing the reducer we want to test, and then feeding a series of actions and expections along the way, ideally in a declarative fashion with little boilerplate! Simplifying testing state
— 2:03
Let’s start with the first problem: asserting against all of a reducer’s state is pretty verbose.
— 2:08
For example, if we look at our simplest counter view test: func testIncrButtonTapped() { var state = CounterViewState( alertNthPrime: nil, count: 2, favoritePrimes: [3, 5], isNthPrimeButtonDisabled: false ) let effects = counterViewReducer(&state, .counter(.incrTapped)) XCTAssertEqual( state, CounterViewState( alertNthPrime: nil, count: 3, favoritePrimes: [3, 5], isNthPrimeButtonDisabled: false ) ) XCTAssertTrue(effects.isEmpty) }
— 2:11
We assert against updated state by recreating it in its entirety, but we really only care about a single line: count: 3,
— 2:31
The count is the only field that’s different from initial state, but it’s difficult to see which fields, if any, have changed without manually inspecting and comparing each initializer call.
— 2:41
We could use the assertion’s message to better capture our intent. XCTAssertEqual( state, CounterViewState( alertNthPrime: nil, count: 3, favoritePrimes: [3, 5], isNthPrimeButtonDisabled: false ), "Expected count to increment to 3" )
— 2:51
But this makes the test even more verbose with information that can quickly become outdated from what the assertion is actually trying to capture. // "Expected count to increment to 3"
— 2:59
Also, CounterViewState contains just four fields. You can imagine much larger types in typical application code, so testing in this fashion will quickly become unsustainable. It might be better to simplify how we assert so that our tests describe our expectations a bit more directly.
— 3:12
We could make a mutable copy of our state before we feed the original to our reducer. func testIncrButtonTapped() { var state = CounterViewState( alertNthPrime: nil, count: 2, favoritePrimes: [3, 5], isNthPrimeButtonDisabled: false ) var expected = state let effects = counterViewReducer(&state, .counter(.incrTapped))
— 3:18
That way we could apply mutations of just what we expect to change. In this case, the count: // XCTAssertEqual( // state, // CounterViewState( // alertNthPrime: nil, // count: 3, // favoritePrimes: [3, 5], // isNthPrimeButtonDisabled: false // ) // ) expected.count = 3 XCTAssertEqual(state, expected) XCTAssertTrue(effects.isEmpty) }
— 3:35
Nine lines turn into one, and our expectation is clearly represented in the mutation. Much more so than the previous blob of state.
— 3:43
But a large portion of our test is still devoted to initializing state: we’re invoking the entire initializer at the beginning of each test, which makes things a bit more brittle than they have to be: if the CounterViewState struct changes, any test constructing it will fail to compile and will need to be updated, regardless of whether those changes affect any particular test.
— 4:00
One thing we can do is update CounterViewState ’s initializer with some reasonable default values. public struct CounterViewState: Equatable { public var alertNthPrime: PrimeAlert? public var count: Int public var favoritePrimes: [Int] public var isNthPrimeButtonDisabled: Bool public init( alertNthPrime: PrimeAlert? = nil, count: Int = 0, favoritePrimes: [Int] = [], isNthPrimeButtonDisabled: Bool = false ) { The Swift compiler will now let us plug in just the values that a particular test cares about.
— 4:16
In the case of testIncrButtonTapped , we only really care about the count, because it is the only field that is mutated. func testIncrButtonTapped() { var state = CounterViewState(count: 2) var expected = state let effects = counterViewReducer(&state, .counter(.incrTapped)) expected.count = 3 XCTAssertEqual(state, expected) XCTAssertTrue(effects.isEmpty) }
— 4:29
Now this test is super succinct.
— 4:37
Let’s move on and update the test for decrementing the counter. func testDecrButtonTapped() { var state = CounterViewState(count: 2) var expected = state let effects = counterViewReducer(&state, .counter(.decrTapped)) expected.count = 1 XCTAssertEqual(state, expected) XCTAssertTrue(effects.isEmpty) }
— 5:05
Nice and easy! 21 lines have become 9 lines that are laser-focussed on the details they care about.
— 5:08
Alright, our next test is a bit more substantial. In fact, it’s one of the most complicated tests we’ve written so far. It runs the entire flow of tapping the nth prime button, evaluating the effect of getting an nth prime response, and finally dismissing the resulting alert. func testNthPrimeButtonHappyFlow() {
— 5:28
It’s a whopping 62 lines of code. We should be able to substantially trim things if we focus in on just the state we care about, though.
— 5:35
First we can simplify the instantiation of state by focussing in on what the test cares about: the prime alert state and whether or not the prime button is disabled. var state = CounterViewState( alertNthPrime: nil, isNthPrimeButtonDisabled: false )
— 5:50
And then we can make a mutable copy of it to track the state changes we expect over time. var expected = state
— 5:54
When the nth prime button is tapped, we expect the button to go disabled, so we can mutate our expected state and assert against it. var effects = counterViewReducer( &state, .counter(.nthPrimeButtonTapped) ) expected.isNthPrimeButtonDisabled = true XCTAssertEqual(state, expected) XCTAssertEqual(effects.count, 1)
— 6:11
And when we get a response, we expect the prime alert state to be set and the button to re-enable. effects = counterViewReducer(&state, nextAction) expected.alertNthPrime = PrimeAlert(prime: 17) expected.isNthPrimeButtonDisabled = false XCTAssertEqual(state, expected) XCTAssertTrue(effects.isEmpty)
— 6:26
Finally, when the alert dismiss button is tapped, we expect the prime alert state to go nil. effects = counterViewReducer( &state, .counter(.alertDismissButtonTapped) ) expected.alertNthPrime = nil XCTAssertEqual(state, expected) XCTAssertTrue(effects.isEmpty)
— 6:37
Alright, we’re starting to get somewhere. The test almost fits on a single screen! The shape of a test
— 6:50
While a lot of the boilerplate around state has shrunken substantially, there’s still an awful lot of ceremony happening in this test:
— 6:56
Perhaps the most glaring are how we handle effects. Effects have a lot of boilerplate attached: we need to manually hold onto them, manually perform them with the sink method, and do an XCTestExpectation dance that involves creating an expectation, waiting for it, and fulfilling it in a completion block. And we can forget many or all of these steps.
— 7:11
Less glaring is the local, mutable state our tests manage and feed to the reducer over the course of a test. Introducing mutation to a scope, even a local one, makes it a little more difficult to reason about. And in this case we have introduced twice the mutable state in the form of that expected copy, so we have twice the chance to accidentally mutate either of these values in the wrong way.
— 7:27
The test has a very obvious shape, but there are a lot of things that we must get right in order to make these assertions. If we step back a bit and look at this shape we’ll notice that every single test we’ve written follows the same script: we construct initial state, we prime our reducer, and then we go through a script of user actions, asserting all of our expectations along the way.
— 7:59
What if we could create an assert helper like Apple’s XCTAssert function, except it could be aware of our architecture. We could provide a description of a whole series of actions the user performed, and at each step along the way we would be able to make an assertion of how the state changed.
— 8:24
What would such an assert helper look like? Well, XCTAssert is just a simple free function, so our helper can be the same: func assert( ) { }
— 8:39
In order for this helper to do its job, it must know the initial value we are starting at and the reducer we want to test. So let’s provide that: func assert<Value, Action>( initialValue: Value, reducer: Reducer<Value, Action> ) { }
— 9:04
And then, given this configuring information, we want to provide the assert helper a list of actions that the user performed when interacting with the UI: func assert<Value, Action>( initialValue: Value, reducer: Reducer<Value, Action>, steps: [Action] ) { }
— 9:22
However, after each step is executed we want to further assert how the model changed from the previous step. So it’s not enough to just provide an array of actions, we want to also provide mutating functions that describe how we expect the value to change: func assert<Value, Action>( initialValue: Value, reducer: Reducer<Value, Action>, steps: [(action: Action, update: (inout Value) -> Void)] ) { }
— 9:59
If we could implement such a function, then we could update our simplest test to look like this: func testIncrButtonTapped() { assert( initialValue: CounterViewState(count: 2), reducer: counterViewReducer steps: [ (.counter(.incrTapped), { state in state.count = 3 }) ] ) }
— 10:35
This is a huge improvement. We no longer have the repetition of feeding a mutable variable and actions into the reducer.
— 10:56
We can simplify things a bit by using $0 instead of state , and now the state that changes is really clear: (.counter(.incrTapped), { $0.count = 3 })
— 11:07
Because it’s so easy to maintain this list of steps, we could make a few more simple assertions along the way. steps: [ (.counter(.incrTapped), { $0.count = 3 }), (.counter(.incrTapped), { $0.count = 4 }), (.counter(.decrTapped), { $0.count = 3 }) ]
— 11:23
Because it’s so easy to write this script of tests, we are more empowered to start testing more complex, nuanced, subtle user flows.
— 11:34
We could even reduce one layer of nesting by using variadics instead of an array: func assert<Value, Action>( initialValue: Value, reducer: Reducer<Value, Action>, steps: (action: Action, update: (inout Value) -> Void)... ) { } assert( initialValue: CounterViewState(count: 2), reducer: counterViewReducer steps: (.counter(.incrTapped), { $0.count = 3 }), (.counter(.incrTapped), { $0.count = 4 }), (.counter(.decrTapped), { $0.count = 3 }) )
— 11:49
This compiles and tests run, but of course we aren’t testing anything yet because the assert helper is empty. so let’s start implementing its body.
— 11:57
We want it to iterate through the given steps and run each action in order to make assertions along the way. steps.forEach { step in }
— 12:08
During each step we want to run the reducer with the current step’s action. steps.forEach { step in reducer }
— 12:11
We need a mutable value to pass to the reducer, so let’s copy it outside of the loop and feed it to the reducer along with the current step’s action. var state = initialValue steps.forEach { step in reducer(&state, step.action) } Result of call to function returning ‘[Effect ]’ is unused
— 12:34
Let’s keep things simple and ignore the effects for now, especially since the first assertion we sketched out doesn’t have any.
— 12:50
Now we can assert that mutations happen as we expect them to, which is the work our tests are currently doing in a very manual fashion, where we copy the current value, mutate it, and assert that they are equal. But this time the mutation is captured in the step’s update function. var state = initialValue steps.forEach { step in var expected = state _ reducer(&state, step.action) step.update(&expected) XCTAssertEqual(state, expected) } Global function ‘ XCTAssertEqual(_:_:_:file:line:) ’ requires that ‘Value’ conform to ‘Equatable’
— 13:30
Ah, but we need to constrain Value to be Equatable in order to feed them to the assertion. func assert<Value: Equatable, Action>(
— 14:02
Everything builds and when we run the test… Executed 1 test, with 0 failures (0 unexpected)
— 14:06
Nice, it passes! Improving test feedback
— 14:10
Just to make sure we’re actually making the assertion we expect, let’s break it the expected count. We can increment the expected count where we send the decrement action. assert( initialValue: CounterViewState(count: 2), reducer: counterViewReducer steps: (.counter(.incrTapped), { $0.count = 3 }), (.counter(.incrTapped), { $0.count = 4 }), (.counter(.decrTapped), { $0.count = 5 }) )
— 14:15
When we run the test it fails as expected. Failed: Executed 1 test, with 1 failure (0 unexpected)
— 14:19
But unfortunately, the failure is showing up deep inside the assert helper. XCTAssertEqual(value, expected)
— 14:30
There are some extra steps we must take to get that error messaging play nicely with Xcode. XCTest takes advantage of some Swift features to display its errors inline, so maybe we can enhance our assert helper with the same.
— 14:46
XCTAssertEqual has a few hidden, default arguments, which are responsible for this behavior. func XCTAssertEqual<T: Equatable>( _ expression1: @autoclosure () throws -> T, _ expression2: @autoclosure () throws -> T, _ message: @autoclosure () -> String = "", file: StaticString = #file, line: UInt = #line )
— 14:54
Both #file and #line are special literals that refer to the file and line on which they are appear. When they’re used as default arguments, they refer to the file and line of where the function is called.
— 15:17
In order to highlight the failure where our assert helper is called, we can introduce these literals to its signature. func assert<Value: Equatable, Action>( initialValue: Value, reducer: Reducer<Value, Action>, steps: (action: Action, update: (inout Value) -> Void)..., file: StaticString = #file, line: UInt = #line ) {
— 15:32
And then we can pass them along to the XCTest assertion. XCTAssertEqual(value, expected, file: file, line: line)
— 15:37
When we run the test, the failure now shows up right where we call assert . assert(
— 15:46
This is definitely better, but it’s still not ideal. The actual failure we introduced is way down at the last step. Worse, if we break another expectation, say the first one: (.counter(.incrTapped), { $0.count = 2 }), (.counter(.incrTapped), { $0.count = 4 }), (.counter(.decrTapped), { $0.count = 5 })
— 16:00
We get two failures, which is to be expected, but they’re all on the same line, where assert is called, but pretty far from the actual problem below. It would be much nicer if these kinds of failures were highlighted inline.
— 16:08
One way to pass the location along is to upgrade the steps with additional file and line fields. steps: ( action: Action, update: (inout Value) -> Void, file: StaticString, line: UInt )...,
— 16:21
It’d be nice to utilize default arguments here, as well, but it’s just not possible.
— 16:28
Now we can pretty mechanically pass #file and #line along at the call site. assert( initialValue: CounterViewState(count: 2), reducer: counterViewReducer, steps: (.counter(.incrTapped), { $0.count = 3 }, #file, #line), (.counter(.incrTapped), { $0.count = 4 }, #file, #line), (.counter(.decrTapped), { $0.count = 5 }, #file, #line) )
— 16:37
And to utilize these arguments, we can pass them along to the XCTest assertion. XCTAssertEqual(value, expected, file: step.file, line: step.line)
— 16:48
And when we run our tests, we get much better feedback. The failure appears at the failing step: (.counter(.incrTapped), { $0.count = 2 }, #file, #line), (.counter(.incrTapped), { $0.count = 4 }, #file, #line), (.counter(.incrTapped), { $0.count = 5 }, #file, #line)
— 16:54
This manual work is pretty unwieldy, though. We can make this work automatic by using a function with default arguments, and one way to do just that is to upgrade this tuple to a proper struct with an initializer function.
— 17:14
We can call the struct Step . struct Step { }
— 17:17
It needs to be generic over Value and Action , and it will store an action, a mutation, a file name, and a line number. struct Step<Value, Action> { let action: Action let update: (inout Value) -> Void let file: StaticString let line: UInt }
— 17:34
In order to get those #file and #line defaults we need a custom initializer. init( _ action: Action, _ update: @escaping (inout Value) -> Void, file: StaticString = #file, line: UInt = #line ) { self.action = action self.update = update self.file = file self.line = line }
— 18:00
And now we just need to update assert to use Step instead of a tuple. func assert<Value: Equatable, Action>( initialValue: Value, reducer: Reducer<Value, Action>, steps: Step<Value, Action>..., file: StaticString = #file, line: UInt = #line ) {
— 18:09
To get things compiling we need to prepend each step of our assertion with a capital Step , and delete the trailing #file and #line s. steps: Step(.counter(.incrTapped), { $0.count = 2 }), Step(.counter(.incrTapped), { $0.count = 4 }), Step(.counter(.decrTapped), { $0.count = 5 })
— 18:18
Nice. Everything builds, and when we run our test, the failures are still highlighted on the appropriate lines. Step(.counter(.incrTapped), { $0.count = 2 }), Step(.counter(.incrTapped), { $0.count = 4 }), Step(.counter(.decrTapped), { $0.count = 5 })
— 18:29
Okay, now that we have nice, automatic feedback for failures, let’s get things passing again. Step(.counter(.incrTapped), { $0.count = 3 }), Step(.counter(.incrTapped), { $0.count = 4 }), Step(.counter(.decrTapped), { $0.count = 3 }) Executed 1 test, with 0 failures (0 unexpected)
— 18:36
And everything still works. Trailing closure ergonomics
— 18:38
Because we’ve upgrade our tuple steps with a proper struct and initializer, it might be nice to take advantage of trailing closure syntax. Step(.counter(.incrTapped)) { $0.count = 3 }, Step(.counter(.incrTapped)) { $0.count = 4 }, Step(.counter(.decrTapped)) { $0.count = 5 } Generic parameter ‘Value’ could not be inferred
— 18:53
Unfortunately this does not work. The error message isn’t particularly helpful, but the reason this doesn’t compile is because trailing closure syntax is only valid for the last argument of a function, even if later arguments can be omitted because they have defaults. This includes even our special file and line literals: _ action: Action, _ update: @escaping (inout Value) -> Void, file: StaticString = #file, line: UInt = #line
— 19:11
If we move update to be the final argument, though, things build just fine. _ action: Action, file: StaticString = #file, line: UInt = #line, _ update: @escaping (inout Value) -> Void Actions sent and actions received
— 19:17
We now have a nice, lightweight domain specific language for describing granular changes to a reducer’s state over the course of actions we explicitly send to it, but we haven’t recaptured the work of testing effects. There are two main ways that actions are fed to a store’s reducer:
— 20:02
They are sent explicitly via user action, or
— 20:04
They are fed back into the system via the result of an effect.
— 20:07
It kind of sounds like we should maybe separate these ideas in our domain specific language so that we can have a script that declares what actions a user does and what actions we expect effects to feed back into the system.
— 20:28
Let’s try to describe the nth prime flow with our assert helper. It looks something like this: Current.nthPrime = { _ in .sync { 17 } } assert( initialValue: CounterViewState( alertNthPrime: nil, isNthPrimeButtonDisabled: false ), reducer: counterViewReducer, steps: Step(.counter(.nthPrimeButtonTapped)) { $0.isNthPrimeButtonDisabled = true }, Step(.counter(.nthPrimeResponse(17))) { $0.alertNthPrime = PrimeAlert(prime: 17) $0.isNthPrimeButtonDisabled = false }, Step(.counter(.alertDismissButtonTapped)) { $0.alertNthPrime = nil } )
— 22:28
This looks pretty nice and is pretty succinct! And if we run it, it even passes! But unfortunately it takes us back to a world where we are describing the actions we expect an effect to return, and nothing about the assertion ensures that we got it right. Ideally the steps would document which actions should be sent to the store vs. which actions were received by an effect.
— 23:00
We could start to distinguish this difference by introducing a new field to Step that describes what type of step it is. And we can use an enum to describe each case: the case in which we want to send an action through the reducer, and the case in which we expect to receive an action from an effect. enum StepType { case send case receive }
— 23:23
Then we can update Step accordingly. let type: StepType init( _ type: StepType, _ action: Action, file: StaticString = #file, line: UInt = #line, _ update: @escaping (inout Value) -> Void ) { self.type = type
— 23:39
And we can update our tests with this new information. First, testIncrButtonTapped gets updated with explicit send s: assert( initialValue: CounterViewState(count: 2), reducer: counterViewReducer, steps: Step(.send, .counter(.incrTapped)) { $0.count = 3 }, Step(.send, .counter(.incrTapped)) { $0.count = 4 }, Step(.send, .counter(.decrTapped)) { $0.count = 3 } )
— 23:58
Next, testNthPrimeHappyFlow : assert( initialValue: CounterViewState( alertNthPrime: nil, isNthPrimeButtonDisabled: false ), reducer: counterViewReducer, steps: Step(.send, .counter(.nthPrimeButtonTapped)) { $0.isNthPrimeButtonDisabled = true }, Step(.receive, .counter(.nthPrimeResponse(17))) { $0.alertNthPrime = PrimeAlert(prime: 17) $0.isNthPrimeButtonDisabled = false }, Step(.send, .counter(.alertDismissButtonTapped)) { $0.alertNthPrime = nil } )
— 24:14
This is starting to read really nicely, but of course, the assert helper isn’t using any of this information.
— 24:28
The overall idea of how to make use of the step type is that when a step says to send an action we can invoke the reducer like normal, but we will also keep track of the effects it produced. Then, when we encounter a step that says we are receiving an action we will take the first effect off the array we are tracking, run it, and verify that the action it produced is the one in the step.
— 24:52
To handle the step type, it’s natural to switch over it. switch step.type { case .send: case .receive: }
— 25:09
We can move the work of explicitly calling the reducer into the send branch, which tells us to explicitly send the action through. switch step.type { case .send: _ = reducer(&state, step.action) case .receive: <#code#> }
— 25:19
We’re currently ignoring the effects returned from our reducer. To track them we need to introduce a variable that gets set whenever the reducer is called, and we need to do this outside of the forEach so that each step has access to effects produced by the previous step. var effects: [Effect<Action>] = [] steps.forEach { step in
— 25:33
Inside the loop we can now append any effects returned from the reducer to the array. effects.append(contentsOf: reducer(&state, step.action))
— 25:39
Alright. Now we’re ready to handle any effects that have accumulated in this array when we expect to receive them. In the receive branch we can start by popping the first effect off the array. case .receive: let effect = effects.removeFirst() }
— 25:51
And then we can introduce that expectation dance so that we can run our effect to completion and extract the expected action. Starting by introducing an implicitly unwrapped optional for the action the effect returns and a test expectation. var action: Action! let receivedCompletion = self .expectation(description: "receivedCompletion") Use of unresolved identifier ‘self’
— 26:16
Ah, but because we’re in a free function instead of an XCTestCase we can’t use the expectation method. We can, however, manually construct one via its initializer. let receivedCompletion = XCTestExpectation( description: "receivedCompletion" )
— 26:35
Next, we’ll run the effect with sink , fulfilling the expectation when it completes, and assigning the action when it’s received. effect.sink( receiveCompletion: { _ in receivedCompletion.fulfill() }, receiveValue: { action = $0 } )
— 27:01
At this point we need to wait for this expectation. We don’t have access to the wait method on XCTestCase , but there’s a similar static function on the XCTWaiter class. XCTWaiter.wait(for: [receivedCompletion], timeout: 0.01) Result of call to ‘wait(for:timeout:)’ is unused
— 27:23
While the wait method on XCTestCase causes a test failure, this static function merely returns an XCTWaiter.Result , which has a few cases describing the various outcomes of waiting on an expectation. If the expectation did not fulfill and complete, we should fail: if XCTWaiter .wait(for: [receivedCompletion], timeout: 0.01) != .completed { XCTFail( "Timed out waiting for the effect to complete", file: step.file, line: step.line ) } else {
— 28:18
Otherwise, now that we have our action, we should check that it’s the same as the one we expected, so let’s write a quick assertion. XCTAssertEqual(action, step.action, file: step.file, line: step.line) Global function ‘ XCTAssertEqual(_:_:_:file:line:) ’ requires that ‘Action’ conform to ‘Equatable’
— 28:41
This means we must also update assert to constrain Action to be equatable. func assert<Value: Equatable, Action: Equatable>(
— 28:52
Finally we want to run the reducer again with the extracted action. reducer(&state, action)
— 29:08
And we want to append the effects they return to the array we’re working with. effects.append(contentsOf: reducer(&value, action))
— 29:34
Alright, this was a lot of work! But it’s basically the work we were doing manually before. Now we get to do it just one time, instead of manually repeating it, over and over.
— 30:00
And if we run our test, it still passes! But it’s not asserting more than it did previously. Before when we asserted that we receive d an action, the helper merely took our word for it. Now, the helper actually runs the effects and asserts against the action received before feeding it along.
— 30:30
To prove this, let’s exercise the helper a bit to make sure that it is testing something real. For example, before we properly handled effects in our assert helper the following test would have passed just fine: Current.nthPrime = { _ in .sync { 17 } } assert( initialValue: CounterViewState( alertNthPrime: nil, isNthPrimeButtonDisabled: false ), reducer: counterViewReducer, steps: Step(.send, .counter(.nthPrimeButtonTapped)) { $0.isNthPrimeButtonDisabled = true }, Step(.receive, .counter(.nthPrimeResponse(15))) { $0.alertNthPrime = PrimeAlert(prime: 15) $0.isNthPrimeButtonDisabled = false }, Step(.send, .counter(.alertDismissButtonTapped)) { $0.alertNthPrime = nil } )
— 30:35
This is because the effect wasn’t actually run, and so it doesn’t matter at all what Current.nthPrime does because we are manually replaying what we expect the effect would have done if it were run. However now that assert is actually running effects, and in fact is using the Current.nthPrime effect, this test should fail, and in fact we get two failures: XCTAssertEqual failed: (“CounterViewState(alertNthPrime: Optional(Counter.PrimeAlert(prime: 17)), count: 0, favoritePrimes: [], isNthPrimeButtonDisabled: false)”) is not equal to (“CounterViewState(alertNthPrime: Optional(Counter.PrimeAlert(prime: 15)), count: 0, favoritePrimes: [], isNthPrimeButtonDisabled: false)”) XCTAssertEqual failed: (“Optional(Counter.CounterViewAction.counter(Counter.CounterAction.nthPrimeResponse(Optional(17))))”) is not equal to (“Optional(Counter.CounterViewAction.counter(Counter.CounterAction.nthPrimeResponse(Optional(15))))”) The first failure is due to the fact that the state did not actually change in the way we expect. And the second is due to the fact that the action did not match..
— 31:10
So, our updates to the assert function are letting us capture real effect behavior. When we assert that an action is sent, not only do we assert on how the state changed but we also accumulate all of the effects that were returned from the reducer. Then, when we assert that an action was received, we pluck the first effect from that array, run it, and assert that the action we expected to be received matches the actual action emitted by the effect, and we feed that action back into the reducer to make sure that it changes the state in the way we think it should.
— 31:38
This is packing a huge punch in just a few lines of code. Assertion edge cases
— 31:45
But things aren’t quite right yet. There are a bunch of edge cases that assert will not catch for us, so let’s explore them one by one.
— 32:03
First, if we comment out the middle step, where we expect to receive an action from an effect: assert( initialValue: CounterViewState( alertNthPrime: nil, isNthPrimeButtonDisabled: false ), reducer: counterViewReducer, steps: Step(.send, .counter(.nthPrimeButtonTapped)) { $0.isNthPrimeButtonDisabled = true }, // Step(.receive, .counter(.nthPrimeResponse(17))) { // $0.alertNthPrime = PrimeAlert(prime: 17) // $0.isNthPrimeButtonDisabled = false // }, Step(.send, .counter(.alertDismissButtonTapped)) { $0.alertNthPrime = nil } )
— 32:12
The test still passes, even though there was an effect that should have been performed. This is because we only consider effects when we explicitly expect an action to be received. The send branch of assert will merrily go on its way, even if we haven’t accounted for some pending effects. case .send: effects.append(contentsOf: reducer(&value, step.action))
— 32:29
What we want to do is make sure that there are no pending effects before running the reducer for a sent action. case .send: if !effects.isEmpty { XCTFail( """ Action sent before handling \(effects.count) \ pending effect(s) """, file: step.file, line: step.line ) } effects.append(contentsOf: reducer(&value, step.action))
— 32:54
When we re-run the test, it fails: // Step(.receive, .counter(.nthPrimeResponse(17))) { // $0.alertNthPrime = PrimeAlert(prime: 17) // $0.isNthPrimeButtonDisabled = false // }, Step(.send, .counter(.alertDismissButtonTapped)) { $0.alertNthPrime = nil } failed - Action sent before handling 1 pending effect(s)
— 32:59
If we comment out the final step, however, things go back to passing: Step(.send, .counter(.nthPrimeButtonTapped)) { $0.isNthPrimeButtonDisabled = true } //, // Step(.receive, .counter(.nthPrimeResponse(17))) { // $0.alertNthPrime = PrimeAlert(prime: 17) // $0.isNthPrimeButtonDisabled = false // }, // Step(.send, .counter(.alertDismissButtonTapped)) { // $0.alertNthPrime = nil // } Executed 1 test, with 0 failures (0 unexpected)
— 33:09
This isn’t ideal. The effect that returns an nthPrimeResponse action isn’t being tested. What we want is for our helper to catch these mistakes for us so that we don’t forget to assert against an effect.
— 33:21
To fix this we need to make a final assertion. After looping over all of the given steps, we should fail if the effects array contains any pending effects. if !effects.isEmpty { XCTFail( "Assertion failed to handle \(effects.count) pending effect(s)", file: file, line: line ) }
— 33:46
With this addition our test is failing again: assert( failed - Assertion failed to handle 1 pending effect(s)
— 33:50
Nice! But what if an assertion expects to receive an effect when there are none? We can comment out the first step and comment in the second to do just that: steps: // Step(.send, .counter(.nthPrimeButtonTapped)) { // $0.isNthPrimeButtonDisabled = true // }, Step(.receive, .counter(.nthPrimeResponse(17))) { $0.alertNthPrime = PrimeAlert(prime: 17) $0.isNthPrimeButtonDisabled = false }, Step(.send, .counter(.alertDismissButtonTapped)) { $0.alertNthPrime = nil }
— 34:10
This time when we run the test we get a crash! case .receive: let effect = effects.removeFirst() Fatal error: Can’t remove first element from an empty collection
— 34:14
Because the removeFirst method on arrays returns a non-optional element, it must crash if no such element exists. And while we want tests to fail when they expect an effect and there are none, we don’t want to crash our entire test suite.
— 34:33
So let’s proactively fail when we expect an effect and there are none. case .receive: guard !effects.isEmpty else { XCTFail( "No pending effects to receive from", file: step.file, line: step.line ) break }
— 34:55
Now the test fails a bit more gracefully. steps: // Step(.send, .counter(.nthPrimeButtonTapped)) { // $0.isNthPrimeButtonDisabled = true // }, Step(.receive, .counter(.nthPrimeResponse(17))) { $0.alertNthPrime = PrimeAlert(prime: 17) $0.isNthPrimeButtonDisabled = false }, failed - No pending effects to receive from
— 35:08
This should cover most of the common edge cases. So let’s comment everything back in to get a passing test: Step(.send, .counter(.nthPrimeButtonTapped)) { $0.isNthPrimeButtonDisabled = true }, Step(.receive, .counter(.nthPrimeResponse(17))) { $0.alertNthPrime = PrimeAlert(prime: 17) $0.isNthPrimeButtonDisabled = false }, Step(.send, .counter(.alertDismissButtonTapped)) { $0.alertNthPrime = nil } Executed 1 test, with 0 failures (0 unexpected) Conclusion
— 35:18
Okay, the assert helper is starting to look really good. It’s letting us prepare some state to test with a reducer, and then we can feed it as many actions as we want to assert against with both the mutations we expect and the actions we expect effects to feed back into the system. There are still a few more things we could do to enhance this helpers, like adding support for fire-and-forget effects, but it’s already doing a lot for us.
— 35:50
Let’s clean things up to get a better, global look at what we’ve accomplished.
— 36:02
First, let’s move the assert helper and its dependencies to their own file. Ideally, though, they’d live in their own test helper module that could be imported into any test target that uses the Composable Architecture. import ComposableArchitecture import XCTest enum StepType { case send case receive } struct Step<Value, Action> { let type: StepType let action: Action let update: (inout Value) -> Void let file: StaticString let line: UInt init( _ type: StepType, _ action: Action, file: StaticString = #file, line: UInt = #line, _ update: @escaping (inout Value) -> Void ) { self.type = type self.action = action self.update = update self.file = file self.line = line } } func assert<Value: Equatable, Action: Equatable>( initialValue: Value, reducer: Reducer<Value, Action>, steps: Step<Value, Action>..., file: StaticString = #file, line: UInt = #line ) { var state = initialValue var effects: [Effect<Action>] = [] steps.forEach { step in var expected = state switch step.type { case .send: if !effects.isEmpty { XCTFail( """ Action sent before handling \(effects.count) \ pending effect(s) """, file: step.file, line: step.line ) } effects.append(contentsOf: reducer(&state, step.action)) case .receive: guard !effects.isEmpty else { XCTFail( "No pending effects to receive from", file: step.file, line: step.line ) break } let effect = effects.removeFirst() var action: Action! let receivedCompletion = XCTestExpectation( description: "receivedCompletion" ) _ = effect.sink( receiveCompletion: { _ in receivedCompletion.fulfill() }, receiveValue: { action = $0 } ) if XCTWaiter.wait(for: [receivedCompletion], timeout: 0.01) != .completed { XCTFail( "Timed out waiting for the effect to complete", file: step.file, line: step.line ) } XCTAssertEqual( action, step.action, file: step.file, line: step.line ) effects.append(contentsOf: reducer(&state, action)) } step.update(&expected) XCTAssertEqual(state, expected, file: step.file, line: step.line) } if !effects.isEmpty { XCTFail( """ Assertion failed to handle \(effects.count) pending effect(s) """, file: file, line: line ) } }
— 36:30
And now we can clean up the counter tests file by deleting some commented-out and obsolete test code, and using the assert helper wherever we were previously testing the architecture manually. import XCTest @testable import Counter class CounterTests: XCTestCase { override class func setUp() { super.setUp() Current = .mock } func testIncrDecrButtonTapped() { assert( initialValue: CounterViewState(count: 2), reducer: counterViewReducer, steps: Step(.send, .counter(.incrTapped)) { $0.count = 3 }, Step(.send, .counter(.incrTapped)) { $0.count = 4 }, Step(.send, .counter(.decrTapped)) { $0.count = 3 } ) } func testNthPrimeButtonHappyFlow() { Current.nthPrime = { _ in .sync { 17 } } assert( initialValue: CounterViewState( alertNthPrime: nil, isNthPrimeButtonDisabled: false ), reducer: counterViewReducer, steps: Step(.send, .counter(.nthPrimeButtonTapped)) { $0.isNthPrimeButtonDisabled = true }, Step(.receive, .counter(.nthPrimeResponse(17))) { $0.alertNthPrime = PrimeAlert(prime: 17) $0.isNthPrimeButtonDisabled = false }, Step(.send, .counter(.alertDismissButtonTapped)) { $0.alertNthPrime = nil } ) } func testNthPrimeButtonUnhappyFlow() { Current.nthPrime = { _ in .sync { nil } } assert( initialValue: CounterViewState( alertNthPrime: nil, isNthPrimeButtonDisabled: false ), reducer: counterViewReducer, steps: Step(.send, .counter(.nthPrimeButtonTapped)) { $0.isNthPrimeButtonDisabled = true }, Step(.receive, .counter(.nthPrimeResponse(nil))) { $0.isNthPrimeButtonDisabled = false } ) } func testPrimeModal() { assert( initialValue: CounterViewState( count: 2, favoritePrimes: [3, 5] ), reducer: counterViewReducer, steps: Step(.send, .primeModal(.saveFavoritePrimeTapped)) { $0.favoritePrimes = [3, 5, 2] }, Step(.send, .primeModal(.removeFavoritePrimeTapped)) { $0.favoritePrimes = [3, 5] } ) } }
— 40:12
All of the tests still pass, but they now very succinctly describe exactly what the user does and what effects fed back into the system.
— 40:30
This entire file is now only 80 lines of code, while previously, we had several 60-line tests that made the entire file weigh in at over 200 lines of code. We’ve hidden away a lot of boilerplate and pain in the process of using this helper! This has made writing and reading tests a pleasant experience. Next time: the point
— 40:50
So we’ve now demonstrated that not only is the Composable Architecture we have been developing super testable, but it can also test deep aspects of our application, and it can be done with minimal set up and ceremony. This is key if people are going to be motivated to write tests. There should be as little friction as possible to writing tests, and we should be confident we are testing some real world aspects of our application.
— 41:19
But no matter how cool this is, we always like to end a series of episodes on Point-Free by asking “what’s the point?”. Because although we’ve built some great testing tools and gotten lots of test coverage, it also took quite a bit of work to get here. We are now on the 18th(!) episode of our architecture series, and we’ve built up a lot of machinery along the way. So was it necessary to do all of this work in order to gain this level of testability? And can we not do this type of testing in vanilla SwiftUI?
— 41:41
Unfortunately, we do indeed think it’s necessary to do some amount of work to gain testability in a SwiftUI application. You don’t necessarily need to use the Composable Architecture we’ve been building, but it seems that if you want to test your SwiftUI application you will be inevitably led to introducing some layers on top of SwiftUI to achieve this.
— 42:02
To see this, let’s take a look at the vanilla SwiftUI application we wrote a long time ago, which was our introduction to SwiftUI and the whole reason we embarked on this series of architecture episodes…next time! References Elm: A delightful language for reliable webapps Elm is both a pure functional language and framework for creating web applications in a declarative fashion. It was instrumental in pushing functional programming ideas into the mainstream, and demonstrating how an application could be represented by a simple pure function from state and actions to state. https://elm-lang.org Redux: A predictable state container for JavaScript apps. The idea of modeling an application’s architecture on simple reducer functions was popularized by Redux, a state management library for React, which in turn took a lot of inspiration from Elm . https://redux.js.org Composable Reducers Brandon Williams • Oct 10, 2017 A talk that Brandon gave at the 2017 Functional Swift conference in Berlin. The talk contains a brief account of many of the ideas covered in our series of episodes on “Composable State Management”. https://www.youtube.com/watch?v=QOIigosUNGU Downloads Sample code 0084-testable-state-management-ergonomics Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .