full coverage, all green
Co-written with Yedidya Kennard and Ethan Sharabi
Part two of our Redux workflow series. A well-implemented system is only half the work. The other half is an automated suite of tests proving our system is behaving according to spec. This is a crucial factor in increasing our engineering velocity. One of the main benefits of Redux is clear separation between views and business logic, which also allows the two parts to be tested independently. Testing our business logic translates into testing the various Redux constructs — reducers, selectors and action handlers (thunks).
Presented below is part two of our series about a simple and robust workflow for real life apps. If you haven’t read part one — “Redux Step by Step: A Simple and Robust Workflow for Real Life Apps” — it’s recommended you do so first.
Redux has become one of the most popular Flux implementations for managing data flow in React apps. Reading about Redux, though, often causes a sensory overload where you can’t see the forest for the trees. This also holds for testing Redux projects.
As usual, we’ll start by iterating that there isn’t one right way to test your Redux project. We will present an opinionated flavor that we personally believe in.
One of the biggest motivations for testing is engineering velocity. It may seem that writing tests slows down the development process, but this perceived notion only holds in the short term. Without automated tests, we’ve noticed that our projects can only grow that much before our ability to deliver grinds to a halt.
An automated suite of tests with full coverage is an incredibly powerful tool. It enables new contributors to push code without worry of breaking anything. It battles code rot by alleviating fear of drastic refactoring. It enables faster releases with more confidence. It allows to fix issues in production continuously without waiting for manual regression.
What kind of tests should we be writing? Should we test the entire system together or every unit separately? Consider the following example:
If we were only testing the entire system together, we would need to write 25 tests for full coverage (5x5 flow combinations). A better approach is to mix tests from multiple levels: 5 unit tests for Module A by itself, 5 more unit tests for Module B by itself and 1–2 integration tests of the entire system together.
Applying the multi-level approach to the domain of testing front-end applications yields the famous pyramid of testing:
There’s a lot of ambiguity regarding what the pyramid looks like and the names of the different levels. This is our take:
The different levels of tests have different characteristics. The higher you go up the pyramid, the more brittle and flaky the tests are. They provide more confidence, but are usually more expensive to write and maintain. They also run much slower. A good balance is having a lot of tests from the lower levels and fewer and fewer tests from the higher levels.
The remainder of this post will focus on the foundation of the pyramid of testing — unit tests for business logic. These are the majority of tests we expect to have in our project. We will focus on other levels in future posts.
Our Redux methodology enforces clear separation between components and business logic. Remember the rule: “Place all business logic inside action handlers (thunks), selectors and reducers.” This will allow us to ignore components altogether from this point forward.
We’ll continue the discussion over the Reddit app we’ve implemented in the previous post. It lets the user choose 3 topics from the front page subreddits and then see their posts in a filterable list. Refresh your memory with the code, it’s available here: https://github.com/wix/react-dataflow-example
Running unit tests requires some sort of test runner. Modern runners like jest include everything you need to test like setup and teardown, expectations and mocking.
Our app relies on create-react-app which already ships with jest pre-configured. You can run the following in terminal to set up:
Note that we’re also installing redux-testkit — a library that reduces the boilerplate for testing Redux and makes the process easier and more fun.
The last command npm test
will start jest in watch mode and automatically run our tests as we’re writing them. If your project does not have jest pre-installed, you can install it easily by following these instructions or simply running npm install jest --save-dev
If we’ve been following the methodology, all of our business logic is found in the various Redux constructs like action handlers (thunks), selectors and reducers. All we have to do is practice how to test each one:
Reducers are pure functions that take the existing state and an action and return the new state after the action was applied. The benefit of pure functions is that there are no side effects so no mocking is required.
One thing we need to watch out for is immutability. Reducers are not allowed to mutate existing state by changing one of its keys in place. This is an example where redux-testkit helps as it verifies immutability for us.
The recommended practice with jest is to colocate tests with the files being tested. Our first reducer is found at src/store/topics/reducer.js
. We’ll place its tests nearby in src/store/topics/__tests__/reducer.spec.js
.
Let’s start with the simplest test for topics reducer. When our reducer is executed without providing existing state and without an action (undefined for both), it should return the initial state:
Next, let’s add some tests that send actions and check that the correct state is returned. For this, we’ll use redux-testkit’s Reducer recipe:
Note that when providing our reducer with existing state, we need to maintain compatibility to how the reducer implementation holds its state. In this case, it uses seamless-immutable which means we must as well.
If your state object is complex and deeply nested, redux-testkit contains other methods for assertions on state deltas or using custom expectations.
The fully tested topics reducer is available here, posts reducer here.
Selectors are also pure functions that take the existing global state and return some derivative data from it. Once again there are no side effects so no mocking is required.
We also need to watch out for immutability here. Selectors are read-only and should not mutate state. It’s easy to miss this when using functions like array.reverse that mutate the object they run on and accidentally change the state when the selector is running. Just like before, redux-testkit helps as it verifies immutability for us.
We’ll colocate the tests with the files being tested, but we’ll separate the selector tests from reducer tests even though the reducer implementation contains selectors in the same file. The topics selectors are found inside src/store/topics/reducer.js
. We’ll place their tests nearby in src/store/topics/__tests__/selectors.spec.js
.
Let’s start with a simple test for the getSelectedTopicsByUrl selector. We’ll test that it returns a correct result when the state is empty (initial). Like before, we’ll use redux-testkit’s Selector recipe:
Note again that when providing our selector with existing state, we need to maintain compatibility to how the reducer implementation holds its state. In this case it uses seamless-immutable which means we must as well.
We should add a couple more tests to this selector to cover cases where the state is not empty:
Note that redux-testkit contains other methods like execute if you prefer using custom expectations.
The fully tested topics selectors are available here, posts selectors here.
According to our methodology, almost every action we export (to be dispatched by views) is a thunk. Thunks wrap synchronous or asynchronous functions that perform the action. They can also cause side effects like accessing servers, which are normally mocked when writing unit tests.
What do we need to assert when unit testing a thunk? The main output of a thunk is dispatching other actions — mostly plain objects actions that trigger state modifications in reducers. Thunks can also dispatch other thunks. This means we should set expectations over what was dispatched. In addition, since thunks can cause side effects like accessing servers, we can set expectations over these as well.
As usual, we’ll colocate the tests with the files being tested. The topics actions are found inside src/store/topics/actions.js
. We’ll place their tests nearby in src/store/topics/__tests__/actions.spec.js
.
Let’s start with testing the fetchTopics action. We’ll test that it triggers the expected dispatches. Since this is a unit test, we’re not going to actually perform the dispatches — they will be mocked. We’ll use redux-testkit’s Thunk recipe:
As your system gets more complicated, you’ll probably have one thunk dispatching another thunk. This is an interesting case to test. We can see an example of this case in our Reddit app in the selectTopic action.
Our best practice is to always consider different thunks as different units — even if they’re found in the same file. This means that when unit testing a thunk that dispatches another, we will not actually execute the second thunk — we’ll always mock it. In order to improve our test, it’s also a good idea to give an explicit name to the anonymous internal function of the second thunk:
Our helper library, redux-testkit, poses several limitations when testing a thunk that dispatches another thunk. We cannot set expectations on the arguments sent to the second thunk and we cannot mock its return value. It may seem at first that these limitations lower the extensiveness of our tests, but they actually enforce correct treatment of sibling thunks.
If you have a use-case that suffers from these limitations, you’re probably not treating different thunks as different units — and this is usually a code smell. Further discussion is beyond the scope of this post, but the topic is so important that we’ve decided to dedicate a short post to it — “Redux Thunks Dispatching Other Thunks — Discussion and Best Practices”.
So what would the unit test of selectTopic action look like?
The fully tested topics actions are available here, posts actions here.
Services are abstraction facades for external API (like backend servers). They are stateless and usually contain pure logic. A common side effect for services is to use fetch to make HTTP requests — which we will have to mock. For this purpose, we’re going to use jest-fetch-mock. Install it withnpm install jest-fetch-mock --save-dev
As usual, we’ll colocate the tests with the files being tested. The reddit service is found inside src/services/reddit.js
. We’ll place its tests nearby in src/services/__tests__/reddit.spec.js
.
Let’s start with a simple test for the getDefaultSubreddits command:
These tests are also very convenient during development because they let us work against example data that follows the contract. For this test, we’ve manually recorded the Reddit API and placed the recording as a JSON in reddit.subreddits.json. It’s actually more convenient to write the tests first and only then complete the implementation that fulfills them.
The fully tested reddit service is available here.
Up until now, all of our tests focused on a single unit — a single reducer, a single selector, a single thunk or a single service. Next on our agenda is testing how our units interconnect — in other words, integration testing.
There’s a lot of ambiguity regarding the term “integration testing” — it all depends on what integrates with what. Let’s be clear and define exactly what plays a part in our case. Our focus is on integration between business logic units in our client. Not integration between client and server. Not integration between business logic and UI.
Working with Redux, our business logic is neatly separated from the rest of the app and located under our store. In order to test all of the Redux constructs together, we’ll need an actual Redux store instance. There’s no reason not to use the real thing, so we’ll create a store instance in the exact same way we create our store in our production code.
What is the input that will drive our test scenarios? Looking back on the Flux architecture diagram, the only way to trigger a change in the store is by dispatching an action. This means actions will be our inputs.
What are the assertions we’ll make in our scenarios? Looking back on the Flux architecture diagram, the only way to consume our store is by listening on updated state. We normally never access new state directly, but use selectors instead. This means selectors will be used for our assertions.
In order to colocate tests with the files being tested we’ll use the src/store
folder. The topics domain is found under src/store/topics
. We’ll place its tests nearby in src/store/__tests__/topics.spec.js
.
Let’s start with an empty skeleton instantiating the store:
We don’t need much help from redux-testkit because all we’re doing is using the official Redux API. The only helper we’ve added is a middleware called FlushThunks. This middleware keeps track of all thunks that have been dispatched. It’s useful for the case where one thunk dispatches another thunk and lets us wait until all promises have been resolved before running our assertions. If you don’t have thunks dispatching other thunks, you can remove this middleware.
Let’s implement an interesting topics-related integration scenario: We’ll start by fetching all topics from the reddit service. Then select just two of them and verify that our selection remains invalid (3 are needed). We’ll then select the third and verify that the selection becomes valid. Once 3 topics are selected, the system prefetches posts from these topics by dispatching a posts action. We’ll finish by verifying that prefetch succeeded and the correct posts found their way to our store. The last action is a thunk dispatching another thunk — so we’ll need to use FlushThunks to wait for everything to subside:
It may seem that this scenario repeats aspects that were already tested in our previous unit tests. This is correct. By definition integration tests take several units and combine their flows.
The idea is to test a small number of happy flows between units. There’s no need to check every possible combination. Let’s take the above scenario, there’s no need to add another test checking what happens when a fourth topic is selected (we expect the first to be replaced). We’ve already covered these edge cases in our extensive unit tests.
Remember our pyramid. The higher up we go, the less scenarios we need to write. The overall number of integration scenarios should be significantly lower than unit scenarios. Unsure which integration scenarios you should write? Try to find actions you haven’t covered in an integration scenario yet and build one around them.
The fully tested topics domain is available here, posts domain here.
We’ve completed our game plan, our business logic should be covered. The full code of our example Reddit app including full tests for business logic is available on GitHub in the following branch:
wix/react-dataflow-example_react-dataflow-example - Experimenting with different dataflow approaches for a real life React app_github.com
How can you tell if you have adequate test coverage? One way is to use jest coverage reports, which indicate the percentage of lines of code that were actually executed during the tests. Generate the report by running:
When testing business logic, we should be able to get to 100% coverage without much difficulty. Remember that full coverage does not necessarily mean the tests are meaningful and we can now deploy with blind confidence. But if you have less than 100% — they are definitely lacking.
This is the coverage report from the tests we’ve written for our Reddit app:
Our service src/services/reddit.js
still needs a little work. We can see the untested lines, and they seem like an edge condition which we may even choose to ignore.
Since we’re only testing business logic, we can limit the report to relevant directories — src/store
and src/services
— with this command:
There’s an art to writing good tests. If you write too little tests, you’ll not gain the confidence that full coverage provides. If you write too many, they’ll become a hassle to maintain without adding value.
One of the methods that guarantees writing exactly the right amount of tests is TDD — Test Driven Development. We are big fans of TDD in Wix, but we’ve not been successful in creating a TDD workflow that feels natural with Redux. This eventually brought upon the creation of remx — an interesting mix of idiomatic MobX inspired by the dogma of Redux. More on that soon.