Testability in Redux
Thunk vs Loop vs Saga
When you use my referral link above 👆 to become a Medium member, all proceeds will be donated towards the construction of the Silicon Wat Campus for children in Ukraine and Cambodia ❤️
Introduction
Redux is a predictable state
container, especially for React applications. Like React, Redux also has a small API surface. To master Redux is to master patterns, not API abstractions. The essential pattern in Redux mirrors the MVC pattern of separating the Model, from the View, from the Controller. In the vernacular of Redux, this translates to reducers, action creators, and middlewares, respectively.
In Redux, a single state
object manages the entirety of an application, no matter how large or complex. For big projects, the main state
can be splitted into many little slices
. This means the root reducer can be composed from smaller ones, each managing one branch of the state tree. Such a pattern makes inspecting and debugging, hydrating and persisting of state
easy. In fact, “time-travel” debugging becomes possible and trivial.
Reducers specify how actions transform the state
tree. To change the state
is to emit an action. An action is simply an object
that describes what happened, while action creators are factories
that return those actions. When an action is dispatched, it passes through a middle layer before reaching the reducer. Inside this middle layer, middlewares can be created to intercept actions and attach business logic.
Altogether, reducers, middlewares, and action creators separate the state from the logic from the calling events, respectively. They are all functions
that should be kept pure. When a function is pure, the same input
is always guaranteed to result in the same output
. A one-to-one relationship between input and output makes functions predictable and easily testable. In practice, however, not all functions can be pure, especially asynchronous ones. This is unfortunate because the web is essentially asynchronous in nature. To address this problem, ES6 allows the creation of generators that makes asynchronous code behave in a synchronous fashion. The effect, asynchronous functions can become pure and thus trivial to test!
To fully appreciate this new state of the art, let’s first examine the most popular pattern in current practice: the Thunk
If you’re looking for A Complete Frontend Developer Course for Beginners, check out this textbook written by Thon Ly exclusively for Medium:
To help you achieve full mastery, all three web languages (HTML, CSS, and JavaScript) are taught together in parallel within a musical context in order to deepen your understanding of their interrelationships in a fun and memorable way!
Thunk
A thunk is the most popular pattern to make asynchronous API calls possible in Redux applications. Essentially, a thunk is a function
that returns another function
— a higher-order action creator
to be exact. Instead of dispatching actions, the concept is to dispatch functions
which then dispatch actions
. This way, business logic can be inserted (such as making API calls) before letting actions propagate. These additional logics are known as side effects in the Redux idiom.
For example, take a look at this API that retrieves a user’s GitHub profile:
In Redux, this usually means creating three action creators
that correspond to the three states of an API call: fetching, success, and fail
Then, we can create a reducer
that updates the state
accordingly:
In Redux, it’s crucial to update a copy of the state
and return that instead of the original. Immutability is necessary because Redux shallowly checks for reference changes. Either way, immutable data makes data handling safer.
Now, we can create a thunk to call an API endpoint before dispatching the appropriate actions:
To make this thunk
work, we need to register a middleware that watches for it:
Finally, we can make API calls simply by dispatching the thunk
we created. Though this implementation has the desired functionality, it breaks one of Redux’s three core principles:
The only way to change the state is to emit an action.
Although actions are ultimately emitted to change our state, technically we’re emitting thunks
instead of actions
. This has the undesirable effect of coupling the logic too closely with the calling event, thus breaking the MVC pattern of separation. Take a look at this live application that connects our thunk
to a React component
:
Our action creators
and reducer
are all pure, so it’s straightforward to test:
The code to test our reducer
:
However, our thunk
is impure and therefore unpredictable. Moreover, its asynchronous code makes it notoriously difficult to test:
To fully test our asynchronous thunk
, we need to create a mock API server. It’s non-trivial to implement, so forgive the author for ending the testing here! 😊
Loop
Similarly, Redux Loop reverses the pattern in thunk, coupling the logic too closely to the state.
TK
Saga
Thankfully, the saga pattern makes mock API servers irrelevant! A saga is a function
that utilizes generators to control the flow of execution. Generators even have the ability to “pause” execution until an asynchronous call completes! In effect, asynchronous functions can be written to have very predictable behaviors. Such purity makes testing effortless, even if it involves making API calls to a backend!
To refactor our GitHub application to use the saga pattern, we replace our thunk
middleware with this saga
middleware (using Redux Saga):
Whenever the FETCH
action is dispatched, this middleware will catch it. Then, the middleware will pass it to the getUserSaga
:
Notice in line 3
that we can assign the result of a API call to a variable
! Unlike the thunk, this saga pattern allows us to dispatch actions
instead of functions
, even though it involves asynchronous side effects. By abiding to Redux’s core principle of keeping actions pure, a saga
decouples the logic from the calling event. Architecturally, this means sagas
are contained inside the middle layer, or the “C” in the MVC pattern of separation.
Take a look at our new refactored application:
The saga pattern really shines when it comes to testing API calls:
A yield
statement is incredible because we can insert any value in its place on the next
call. Take a look at the second test in line 22
. We can tell our generator function getUserSaga
that we want the result of our GitHub call to be state.user
! This one line makes mocking API servers obsolete! 😉
Moreover, we could even throw an error
to catch the FAIL
test case! Basically, what was once notoriously difficult is now trivial!
Reference: https://redux.js.org/recipes/writingtests
When you use my referral link above 👆 to become a Medium member, all proceeds will be donated towards the construction of the Silicon Wat Campus for children in Ukraine and Cambodia ❤️
Conclusion
The beauty of Redux lies in its patterns. Utility functions exist only to tie together those contracts. As a result, Redux has a refreshingly small API surface. The overarching pattern separates the state from the logic from the calling events that parallels MVC architecture. In the language of Redux, events dispatch actions that travel through middlewares before reaching the reducers. As such, Redux pairs nicely with “View” libraries such as React, where UI updates can be inferred from state changes, and components can easily dispatch actions to update the state and rerender. As the state grows larger for complex applications, reducers
that manage it can be composed from smaller ones, much like composition of components
in React. In Redux, it’s crucial never to mutate state, as doing so is an antipattern.
Ultimately, all this is done to ensure that functions
are predictable and pure to make testing easier. Though a popular pattern, thunks
are impure and fail to separate the “C” from the “V” in the MVC pattern. Similarly, loops
fail to separate the “C” from the “M”. By utilizing generators, sagas
are able to contain all the side effects inside the “C” in addition to making asynchronous code behave synchronously. Incredibly, testing asynchronous logic becomes trivial! More and more, quality is becoming the key differentiator, which depends heavily on accurate and precise testing. Testability in turn depends on a clean and clear separation of concerns.
If quality is important to you, testing should be too.
If you like this article, you might also like: