Unlock This Episode
Our Free plan includes 1 subscriber-only episode of your choice, plus weekly updates from our newsletter.
Introduction
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.
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.
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.
There is a lot of ceremony in our tests right now. We must:
- 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.
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.
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!
Subscribe to Point-Free
Access this episode, plus all past and future episodes when you become a subscriber.
Already a subscriber? Log in
Exercises
Extract the
assert
helper to aComposableArchitectureTestSupport
module that can be imported in all of the test modules.Solution
- Create a new iOS framework.
- Move the test support file into the module’s group (and double check that the file is included in the test support framework target.
- If you try to build the test support module, it will fail when it tries to link to XCTest. It’s not well-documented, but with some internet sleuthing you may come across a solution, which is to add
-weak-lswiftXCTest
as a linker flag to the test module’s build settings. - Add
ComposableArchitectureTestSupport
as a dependency to all of the test modules that need it.
You may also need to add the following framework search paths:
$(DEVELOPER_FRAMEWORKS_DIR) $(PLATFORM_DIR)/Developer/Library/Frameworks
Update the prime modal tests to use the
assert
helper.Solution
PrimeModalState
is a tuple, and incompatible with theassert
helper because tuples are non-nominal types that cannot conform to protocols, likeEquatable
. While we could write an overload ofassert
that supports tuples of state, let’s instead take the opportunity to upgrade the module’s root state value to a proper struct that conforms toEquatable
. This requires a little boilerplate of a public initializer.public struct PrimeModalState: Equatable { public var count: Int public var favoritePrimes: [Int] public init( count: Int, favoritePrimes: [Int] ) { self.count = count self.favoritePrimes = favoritePrimes } }
This is enough to write some tests, but let’s make sure the app still builds by fixing the counter module.
First, we must update
CounterViewState
’sprimeModal
property to work with a struct instead of a tuple.var primeModal: PrimeModalState { get { PrimeModalState(count: self.count, favoritePrimes: self.favoritePrimes) } set { (self.count, self.favoritePrimes) = (newValue.count, newValue.favoritePrimes) } }
Second, we should delegate to this property when projecting into this state for the view.
IsPrimeModalView( store: self.store .view( value: { $0.primeModal }, action: { .primeModal($0) } ) )
We’re finally ready to upgrade our tests! We can even combine them into a single test that exercises saving and removing at once.
func testSaveAndRemoveFavoritesPrimesTapped() { assert( initialValue: PrimeModalState(count: 2, favoritePrimes: [3, 5]), reducer: primeModalReducer, steps: Step(.send, .saveFavoritePrimeTapped) { $0.favoritePrimes = [3, 5, 2] }, Step(.send, .removeFavoritePrimeTapped) { $0.favoritePrimes = [3, 5] } ) }
Let’s start updating the favorite primes tests to use the
assert
helper. In this exercise, updatetestDeleteFavoritePrimes
.Solution
func testDeleteFavoritePrimes() { assert( initialValue: [2, 3, 5, 7], reducer: favoritePrimesReducer, steps: Step(.send, .deleteFavoritePrimes([2])) { $0 = [2, 3, 7] } ) }
Update
testLoadFavoritePrimesFlow
to use theassert
helper.Solution
func testLoadFavoritePrimesFlow() { Current.fileClient.load = { _ in .sync { try! JSONEncoder().encode([2, 31]) } } assert( initialValue: [2, 3, 5, 7], reducer: favoritePrimesReducer, steps: Step(.send, .loadButtonTapped) { _ in }, Step(.receive, .loadedFavoritePrimes([2, 31])) { $0 = [2, 31] } ) }
Try to update
testSaveButtonTapped
to use theassert
helper. What goes wrong?Solution
We might try to update this test with the following:
func testSaveButtonTapped() { var didSave = false Current.fileClient.save = { _, data in .fireAndForget { didSave = true } } assert( initialValue: [2, 3, 5, 7], reducer: favoritePrimesReducer, steps: Step(.send(.saveButtonTapped) { _ in }) ) XCTAssert(didSave) }
But when we run it, it fails:
❌ failed - Assertion failed to handle 1 pending effect(s) ❌ XCTAssertTrue failed
The
assert
helper only runs effects when it expects to receive an event from one, which means it’s not equipped to handle fire-and-forget logic.Update the
assert
helper to support testing fire-and-forget effects (like the one ontestSaveButtonTapped
). This will involve changing the wayStepType
andStep
look so that they can describe the idea of fire-and-forget effects that can be handled inassert
.Solution
There are a few ways to account for fire-and-forget effects with our test helper. One thing we could do is upgrade
StepType
with the idea of a step that accounts for afireAndForget
effect.enum StepType { case send case receive case fireAndForget }
This makes
Step
a bit more complicated: it has a non-optional action and update function, but neither of these are relevant to fire-and-forget effects because they cannot feed actions back to the system and mutate state.We could make the action optional and get things building, but that would allow us to describe some truly nonsensical steps, including:
- A
send
step with anil
action - A
receive
step with anil
action - A
fireAndForget
step with an action or a mutation (or both!)
Let’s use some of the lessons of Algebraic Data Types to refactor
Step
andStepType
to eliminate these impossible states.Both
send
andreceive
care about the associated data of the action and mutation, whilefireAndForget
does not. We can push this data deeper intoStepType
as associated values, and we can nestStepType
inside ofStep
so that it gets access to theValue
andAction
generics.struct Step<Value, Action> { enum StepType { case send(Action, (inout Value) -> Void) case receive(Action, (inout Value) -> Void) case fireAndForget } let type: StepType let file: StaticString let line: UInt init( _ type: StepType, file: StaticString = #file, line: UInt = #line ) { self.type = type self.file = file self.line = line } }
Now, we must update
assert
to extract these values and exhaustively switch on fire-and-forget effects.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 let .send(action, update): if !effects.isEmpty { XCTFail("Action sent before handling \(effects.count) pending effect(s)", file: step.file, line: step.line) } effects.append(contentsOf: reducer(&state, action)) update(&expected) XCTAssertEqual(state, expected, file: step.file, line: step.line) case let .receive(expectedAction, update): 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") let cancellable = 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, expectedAction, file: step.file, line: step.line) effects.append(contentsOf: reducer(&state, action)) update(&expected) XCTAssertEqual(state, expected, file: step.file, line: step.line) case .fireAndForget: guard !effects.isEmpty else { XCTFail("No pending effects to run", file: step.file, line: step.line) break } let effect = effects.removeFirst() let receivedCompletion = XCTestExpectation(description: "receivedCompletion") _ = effect.sink( receiveCompletion: { _ in receivedCompletion.fulfill() }, receiveValue: { _ in XCTFail() } ) if XCTWaiter.wait(for: [receivedCompletion], timeout: 0.01) != .completed { XCTFail("Timed out waiting for the effect to complete", file: step.file, line: step.line) } } } if !effects.isEmpty { XCTFail("Assertion failed to handle \(effects.count) pending effect(s)", file: file, line: line) } }
- A
Using the updated
assert
helper from the previous exercise, rewritetestSaveButtonTapped
.Solution
func testSaveButtonTapped() { var didSave = false Current.fileClient.save = { _, data in .fireAndForget { didSave = true } } assert( initialValue: [2, 3, 5, 7], reducer: favoritePrimesReducer, steps: Step(.send(.saveButtonTapped) { _ in }), Step(.fireAndForget) ) XCTAssert(didSave) }
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.
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.
Composable Reducers
Brandon Williams • Tuesday Oct 10, 2017A 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”.