Writing more testable code with Redux Saga
I was looking for an open source project to contribute and accidentally found the Crypticker, a minimalist site dashboard for cryptocurrencies.
The entire project but a single and important module is well tested: the action creators. Some actions were implemented with thunks and using the global fetch function to make http requests. In order to test that, we would need to mock the global fetch assigning a new function for it, it does not seem like a good idea because we would be changing a global state of the runtime for tests and this could cause errors in other tests.
Before moving into sagas, I removed all thunks from the project and made the action creators pure functions. To avoid boilerplate, I have decided to use redux-actions a simple module for creating action creators.
After this change I could remove the thunk middleware and its module.
So let’s introduce Redux Saga to the project.
A saga function is a generator function that will generate effects descriptions to be handled by the saga middleware.
The first saga function will be the entry point for the application. It will be responsible for:
- loading the tickers and currencies if not loaded
- forking another saga for periodic refresh of the dashboard information
- and reloading the tickers and currencies whenever the action TICKER/GET_CURRENCIES_AND_TICKERS occur.
This first saga is pretty simple. It is selecting the tickers from the store using the select effect passing a selector function:
export const getTickers = state => state.tickers;
If the selector returns no tickers, it immediately generates a call effect for another saga to load the currencies.
Then it generates a fork effect that will start another saga in parallel. The difference between call and fork is that call waits for the other saga to complete and return its result. Fork will only start the other saga as a task but won’t wait for it’s completion.
And finally it will generate a takeEvery effect that will trigger the getCurrenciesAndTickers saga every time that an action with the type ‘TICKER/GET_CURRENCIES_AND_TICKERS’ is dispatched. The takeEvery effect does not block the saga.
Now let’s take a closer look in the getCurrenciesAndTickers saga:
In this saga we are using two new effects put and all.
The put effect is simple, it will dispatch an action in the redux store.
The all effect is signalizing that we want to execute all the effects passed to it in parallel and receive all their results when they are done. In the saga above, it was used to execute both calls to fetch in parallel.
You probably also noted two new uses for the `call` effect.
It’s setting the params for the fetch function
And this is more tricky but it’s a way to tell saga that we want to call the json function bound to the response object.
And lastly the periodicRefresh saga:
In this saga, we are fetching the values for the tickers and setting it in the store. After that we are going to race for one of these things to happen:
- pass 60 seconds
- an action of type `TICKER/REFRESH` to be dispatched
- an action of type `TICKER/DEACTIVATE_EDIT_MODE` to be dispatched
- an action of type `TICKER/ADD` to be dispatched
When the first of these things happen, it will fork itself one more time, reloading the ticker values and waiting for these triggers again. It will periodic refresh the ticker values in a sort of recursive way!
With all the sagas created I just need to install the saga middleware and start the root saga:
The whole motivation for this migration is make the code more testable, so let’s test it!
For testing sagas, I used the redux-saga-test-plan module. It helps to make assertions in the effects that the saga functions generates.
Here is how I tested the periodicRefresh saga.
Check out the tests for the all the sagas here.
Using sagas instead of thunks to handle side effects makes the code more easily testable and more organized because we are not mixing action creators with side effect handling.
You can check the code of the application using redux saga in my fork of the project.