Welcome back to another article on the Composable Architecture. Today I’d like to explore how to test the state updates of our app.
Testability is one of the pillars of the Composable Architecture by pointfree.co: this concept is so important that they created a whole sub-framework called
ComposableArchitectureTestSupport that is already shipped within the Composable Architecture.
The TestSupport is built on top of the
ComposableArchitecture and the Apple
XCTest framework. In this way, we don’t need to download any other dependency to test our code.
Which unit of our code should we test? The architecture pushes toward implementing the whole app logic in one or more reducers. Therefore the object that must be tested, to ensure that the app is behaving properly, is the reducer itself!
Let’s see together how can we do that!
Create a test project.
In order to write our UI tests, we need a proper testing target.
If you do not have a
SnakeTests folder nor a
SnakeTests target yet, you should:
- In Xcode, click on
- Select the
Unit Testing Bundletemplate for the target and click
- Call it
Xcode will create a
SnakeTests folder with a
Info.Plist file and a sample
SnakeTests.swift file in it.
Open the file and safely remove the
func setUpWithError() throws method, the
func tearDownWithError() throws method and the
func testPerformanceExample() throws method.
We can also rename the
func testExample() throws method into a more meaningful
func testChangeDirection() throws method.
How do tests work in SCA
To properly understand how we can write our tests in SCA, let’s recall some basic concepts of the architecture:
- There is a single
Statefor the whole app that contains all the information about the current execution.
- The state is held by a
Storewhich is the only entity allowed to update it.
- To perform a
Stateupdate, we have to use
Actions that are sent into the store.
- The function that is responsible to manipulate the state is the
Reducer: it is a pure function that given the
actionand the current
environmentcan return an updated version of the state.
At this point, the path is clearer: we need to create a
Store, send some
actions into it and assert against the new
state that it has been updated as we expected.
Creating the Store
The default store of the Composable Architecture is quite limited: by looking at its interface, it only offers some methods to change its shape. It is even impossible to send an action into it and we can not inspect its internals.
This has been done to foster encapsulation and prevent wrong usages of the
Store. However, we have a strong need to create a store we can interact with and that we can observe. Luckily, the
TestSupport comes to the rescue: it offers a
TestStore that, not only allows us to send us some actions into it, but it also implements several helpers to simplify the test-writing activity.
To create the
TestStore we follow the very same steps of the standard
Store: first, let’s create a state with an initial situation. Then let’s pass a reducer that is able to update the state and, finally, let’s pass the environment. This is the result:
Controlling your tests.
The most attentive among the readers have already spotted something weird. The
AppState is created by passing a Snake (as expected) and we are now passing a
generator. This specific generator is a function that always returns
One of the most important pillars of the testing practice is that:
Test must be repeatable.
If you remember, last week we were creating the state by invoking a random function to compute a valid location for the
mouse. Although this is the right behavior we would like to have for the actual game, in a test environment it will make the execution extremely unreliable: how could we check in which position is the mouse if that position changes at every run?
Therefore, we updated the state so that we can use the
Int.random function in production but we can choose the function to use in a test environment.
The new state looks like this:
As you can see, we are passing an escaping closure to the
randomLocation function to compute the
randomCol variables. In production, the default generator (i.e.:
Int.random(in:) ) is used. In the test environment, we are passing a custom function.
This concept is extremely powerful and it is called Dependency Injection: we are injecting a function to compute the (now not so) random location for the mouse.
Asserting the State
Every good test is composed of 3 main steps:
- Arrange: the process to create the elements required to run the test.
- Act: the process to perform the action which we want to test.
- Assert: the process to verify that the outcome of the execution corresponds to the expected outcome.
So far, we executed the first step. We arranged the
Store so that we can act on it and assert the action outcome.
The Composable Architecture Test Support allows us to pack steps 2 and 3 together. The
TestStore offers a very comfortable
assert method we can use to both Act and Assert. Let’s have a look at its signature, together with the relevant types:
assert takes a variadic number of steps that can be executed against the
Step is defined by a
StepType. There are four types of steps:
sendis used to send an action into the reducer
receiveis used every time an effect injects an action in the reducer and we have to assert against it
environmentis used to update the environment, if needed
dois used to perform some operation to prepare the next step or to trigger a new receive.
Steps allows us to perform the Act part of the testing process. The Assert part is encapsulated in the closure
(inout LocalState) -> Void.
In this closure, in fact, the
TestStore provide us with the state before the action execution. Our task in the closure is to prepare the state as we expect it after the execution of the action. Then, the
TestStore will perform an assert between the state computed by the reducer and the state we provided into the closure. There is no more need to write those
In this example, we are sending the four
changeCurrent actions into the reducer in order to test that the state is updated correctly.
.send step, we call the
self.check method that lets us prepare the expected state for the assertion.
Testing Effects and Dependencies
Up to this point, everything looks pretty straightforward:
- We prepare the store
- We send an action into the store
- We prepare the expected state
- We let the
TestStorerun the assertions for us.
Cool! Now, let’s have a look at the following test, where we want to test the behavior when the snake eats the mouse:
The Arrange phase is slightly longer: we need to find a way to place the mouse right in front of the snake. In this way, upon a
.move action, the snake can eat it.
To do so, given that the same generator is used to get both the
row and the
col of the mouse, we can create a function that, when invoked, returns first
5 and then
6. The mouse will be located in position
(row: 5, col: 6), right in front of the snake.
move action to the store. When it comes to the Assert phase, we can establish that:
- The head of the snake is now in the location where there was the mouse.
- The body of the snake has grown, so we added a new body unit where the head was.
- We know that the direction has not changed.
Looks good, right?
Wrong! The test is failing: we need to assert the new position for the mouse otherwise the two states, the one before and the one after the
.move action, won’t match.
A nice thing about the Composable Architecture Test Support is that the error messages are extremely informative: here the message is telling us that the expected state has the mouse in position
(row: 5, col: 6) but the actual state has the mouse in position
(row: 7, col: 1).
To worsen the things, if we run the test again, the actual mouse position will change: by inspecting the code, we can see that this is due to the reducer. Whenever the snake eats a mouse, we compute a new random location for the mouse!
How can we properly test the new state in this context? How can we fully control the new mouse position?
Spoiler alert: we can do it by leveraging the environment and adding a dependency. But this will be covered by next week's article. Stay tuned!