Better Redux-Saga Unit Tests with Redux Dynamic Modules
Saga unit testing felt very tedious to me when I joined La Javaness, and reading Phil Herbert’s article on the topic confirmed my intuition about the issues at hand with Redux-Saga’s unit tests. Sagas produce side effects based on their environments, but we were testing their implementation details while ignoring both the initial environment and the produced side-effects.
By changing how we test sagas from the official method to our own, we improved conditional branch coverage on a 468 line saga from 21.79% in 649 lines to 98.55% in 665 lines. In other words, we now get 4.4 times more coverage per line of test written. Our new testing method found bugs that couldn’t possibly be caught before. Our tests are now easier to read and write, turn out to be more resilient to implementation changes, and provide assurances over a much wider perimeter than the official method.
NB: We assume you know unit testing (with Jest or an equivalent) and Redux. We give brief introductions to Redux-Saga and Redux Dynamic Modules. We recommend you read the Redux-Saga beginner tutorial for an intro.
Table of Contents
- We provide a succinct refresher on Redux-Saga
- We delve into the limitations of the official saga unit testing strategy
- We present a simplified version of our test runner without these limitations, along with a sample test
- We discuss other helper functions we wrote to support those tests
- We explain how to integrate Redux Dynamic Modules in such a test runner
What is Redux-Saga?
The Redux-Saga project is a Redux side-effect manager. Redux-Sagas helps organise and formalise the application logic that processes Redux actions and produces changes to the Redux store. To do this, Redux-Sagas provides effects, a series of instructions to be interpreted by the Redux-Sagas engine, with specific semantics (eg. listening to an action, calling a function, dispatching an action, racing for one of several other effects).
In a Redux-Saga app, each action triggered by UI components (or a third-party system) typically has a dedicated saga processing it. Testing such sagas’ behaviour ensures that events are well responded to, and the application state is in a well-defined state after user actions.
Sagas are implemented using generator functions. This architecture allows each effect to be yielded one by one, allowing the unrolling of a saga in a step by step fashion. According to the Redux-Sagas authors, the use of generator functions allows for ‘side effect testing [to be] quick, concise and painless’. In our experience, however, this promise does not deliver. We find sagas tests with the official approach to be tedious, verbose, implementation-dependent, and brittle. More importantly, they are unable to validate the effects of a saga on a runtime environment where it will be used.
How sagas are tested
The testing method recommended by the official documentation is to test the sequencing of each effect in a saga, step by step. To test a step, one simulates the running of the saga’s previous steps with made-up payloads and then verifies that the next step is the one in the implementation.
Below is an example taken from the official documentation, and translated to use Jest as a test engine:
We’ve been using this approach for a few years now. It has provided limited value to us, as it suffers from a host of limitations.
Step-by-step tests are extremely brittle. They never survive implementation changes, even if the resulting side effects on the Redux store or on third-party systems (the output of the sagas) are identical. Here, we might not care if
DO_STUFF is later replaced by another call, or if it is de-duplicated. It might be irrelevant to the test verifying what colour is returned, and so, that test should not break if it changes.
Those tests are very verbose in our experience, as we must describe and replicate the entire inner structure of the saga in the test.
They can be factorised a little if we use a
cloneableGenerator, but at the expense of readability, as tests are no longer atomic. They can also no longer be run in parallel if
Context is Ignored
Step-by-step tests are run in complete isolation from their context, even when the context is an input source of the saga’s behaviour.
In the above example, what if
DO_STUFF is expected to fail, and the saga to handle that failure in a specific way? What if the initial Redux state affects the numbers returned by
CHOOSE_NUMBER in a predictable, specified fashion? Then, we should verify that the right colour is returned based on the initial Redux state.
Considering the above two sources of context influence, shouldn’t we write non-regression tests with a representative execution environment for the saga, to verify that it behaves accordingly, instead of only testing if
action.payload.numberis odd or even?
Interactions Between Effects are Mocked
expectcalls in the above examples contain made-up data, eg.
clone.next(chooseNumber(3)). What if 2 or 3 are data items that never occur, and what if
CHOOSE_NUMBERis changed to return a string? Our unit tests will continue to work, even though our saga might now fail in a real-world use case because
chooseNumber(“3”) is now passed.
With this method, the interaction between two saga effects, or a saga effect and an action reducer (which influences the Redux store, which later on may be read by another effect of the saga) are never tested. Bugs that involve the interaction of two features are notoriously difficult to find and responsible for many production bugs (eg. they constitute most bugs in the Linux kernel).
By providing the execution environment of a saga, and only mocking unreliable or slow external systems (eg. the backend), we ensure each effect chains its actual output to the next effect, eliminating many uncertainties about the behaviour of our sagas. If a reducer, third-party saga, or utility function in our codebase changes, then we can ensure that the unit tests for sagas that depend on the changed code break. If
CHOOSE_NUMBERnow returns a string, our conditional check will break and we’ll have a chance to fix it instead of sending an interaction feature bug to production.
The arrange-act-assert pattern, traditionally used in unit tests, is not respected by step-by-step saga tests. Actions are mingled with assertions, making it difficult to know what exactly is being tested.
In an AAA test, we can mentally ignore the arrange step, and compare the act step with the assertions to see if it makes sense. In the method we’ll propose below, most tests have a single action dispatch in their act step, making them super easy to comprehend.
Side-Effects are not Tested!
Testing a saga’s internal sequencing of instructions is not where the value lies. The purpose of sagas is to have side effects. Sagas are not pure functions. They do not produce an output based on an input. They produce side effects based on an input and an initial context. Therefore, testing what a saga does without testing what it does to the systems it affects provides no guarantee on whether the saga will transform the Redux store in an expected way.
Unit tests are named so because they’re expected to test a single unit of application logic (eg. a saga function for one action); not because they should be run in a completely artificial environment. The type of unit tests favoured by the Redux-Saga maintainers are called solitary unit tests, but we favour social unit tests because both the initial context of the saga and its resulting context, must be tested to build confidence about how the saga will behave in production.
To go further on unit vs end-to-end tests: One would be absolutely correct to point out that reducers are the functional unit responsible for modifying the Redux state, and that sagas’ role only is to trigger additional actions based on a workflow. There is something counterintuitive about examining the Redux store after running a saga unit test, instead of merely examining its dispatched actions. We make this decision because the way that the reducers operate already constitutes a dependency for a saga.
If your saga dispatches an action A, races between B and C, and then dispatches an action D, the reducer for A already is a dependency, and you already need to guarantee non-regression with regard to that reducer. It makes sense to us that if we treat intermediary reducers as dependencies, we do expect the Redux store to contain specific data. What we test in that last
Treat the last called reducer of the saga in the same way; it’s more conceptually simple that way for the developer writing the tests. Strictly speaking, this specific choice crosses the boundary of a unit test, but it’s done in the interest of pragmatism.
Our Test Runner
To get past these limitations, we’ll write a test runner that builds a Redux store with representative reducers and middleware, and loads up a saga. Each unit test will dispatch one action with one payload, and observe that the saga produces the desired sequence of actions and has the desired side effects on the Redux store and other relevant systems.
Our tests should be able to ensure proper behaviour with regard to:
- side-effects on the Redux store
- side-effects on external systems like
- the ability of saga code to correctly parse real-world action payloads
- feature interactions between the saga and the code it depends on
Below is a synthetic, simplified version of our test runner. For a given state reducer, saga and initial Redux store state, it provides a store against which an action dispatch may be tested. The function returns two variables: the list of
dispatchedactions, and the created
Let’s now look at how we use it. We’ll test the login action of our login saga in a wider authentication saga. Our test suite data includes a mocked Annie Ernaux, accessible via the
annieErnaux variable. Our tests are split in an arrange-act-assert pattern.
In the arrange step, we’ll later show how we mock exchanges with our backend. We choose to mock the backend, as running tests with a real backend would introduce an indeterministic, unpredictable element to our test, as well as slowing it down considerably. In the act step, we dispatch an action to our Redux store.
In the assert step, we verify both the state of the store (hence the use of the
storevariable), other systems, and that the last action dispatched is a success or failure action to ensure the saga exits in the expected code path (hence the use of
dispatched); unlike step-by-step tests, we skip intermediary effect yields to focus on the outcome of what was done in the act step.
There are many ways to mock a backend. The one used in this article is chosen because it’s conceptually simple; in practice, we tend to use Mirage, and we’re experimenting with using the Pact API to generate contracts at the same time we’re running our saga tests.
mockAxiosSuccessreturns a 200 response with a payload. Our
mockAxiosFailurereturns an error response, using the status as defined in our backend’s error payload signature. Finally,
mockAxiosNetworkFailureallows us to simulate a network failure so we can test if a saga gracefully exits when there is no internet or when its backend is down.
Last Action Dispatched
We like to know if our saga control flow ends up in the success or failure branches, so we wrote a helper to test the last dispatched action.
waitForwill stop immediately once the saga has yielded its last value, so it does not timeout when tests fail, and it allows us to abstract away both intermediary steps and the delays inherent to asynchronous code.
Integrating Redux Dynamic Modules
At La Javaness, we use redux-dynamic-modules to bundle together components, Redux store subtrees and sagas, and to lazy-mount them when required. Redux Dynamic Modules provide an interface through which sagas are mounted onto the Redux store, and so, when a Redux Dynamic Modules saga is running, it can expect dependant sagas and reducers to also be available.
Here is an example based on our module responsible for authentication. Note that we export the
moduleConfigobject so we can reuse it later in tests.
Our general saga (which handles
LocalStorageand network requests), our page redirection saga and our auth saga will be mounted when the module is mounted. We’ll also have reducers to handle the
redirect subtrees of our Redux store. To lazy-load our module, we rely on Webpack:
To provide a realistic test environment for sagas, we wanted to replicate their Redux Dynamic Modules environment. We made a test runner generator that takes a Redux Dynamic Module as an input parameter. It computes the reducer and sagas to provide based on the
In our test file, we generate the
configureStorefunction from the module’s config, and the rest of the tests remains unchanged; we now have a fully representative environment upon which to imprint our saga’s side effects.
Et voilà! You now know how to run social unit tests on a saga in a Redux Dynamic Modules environment. This test runner can help you write concise tests that assess the impact of a single action on the Redux store, dispatched actions and any third-party system, based on an initial context. We’re not able to share the exact code we’re using as it is proprietary, but hopefully, the article covers enough details to let you write your own in-house solution.
Other Saga Testing Libraries
Alternative libraries exist, of course. Those are listed on the Redux-Saga website, and appear to be the most used ones in NPM.
redux-saga-testing suffers from most of Redux-Saga’s basic tests limitations as it is also step-by-step and provides only syntactic sugar to the basic tests.
redux-saga-test-engine is another flavour of step-by-step testing, where you describe all steps in your expect call after letting the saga run its course.
redux-saga-test-plan provides tools for social unit tests with side-effect testing, but we find it difficult to read, as it doesn’t allow writing tests that follow the AAA pattern. We’re not quite sure if we can fit every ‘arrange’ instruction we might need in this library’s API.
redux-saga-tester is a social unit test library that has none of the limitations discussed above, and which we encourage you to try. We find it amusing that it is described as an integration testing library by the Redux-Saga authors. If you feel the same way, we suggest reading Martin Fowler’s article on what constitutes a unit test.
We would have used redux-saga-tester if we didn’t use Redux Dynamic Modules, but we felt that we could better integrate our modules’ environment into our saga unit tests with a bit of in-house code. Some of our sagas make calls to other sagas and rely on those other sagas’ reducers, so we had to have Redux Dynamic Modules support for our tests to work.
Did we miss another library? Do you have something in mind that would further improve our test runner? Let us know in the comments!