Redux is a predictable state container. To the uninitiated, these words mean very little. Thankfully, TDD can help us understand how to think about and implement our Redux application architecture. The tests in the chapter will help you see how Redux can be integrated into any application.
The headline benefit of Redux is the ability to share state between components in a way that provides data consistency when operating in an asynchronous browser environment. The big drawback is that you must introduce a whole bunch of plumbing and complexity into your application.
Here be dragons
For many applications, the complexity of Redux outweighs the benefits. Just because this chapter exists in this book does not mean you should be rushing out to use Redux. In fact, I hope that the code samples contained herein serve as warning enough for the complexity you will be introducing.
In this chapter, we’ll build a reducer and a saga to manage the submission of our CustomerForm component.
We’ll use a testing library named expect-redux to test Redux interactions. This library allows us to write tests that are not tied to the redux-saga library. Being independent of libraries is a great way of ensuring that your tests are not brittle and are resilient to change: you could replace redux-saga with redux-thunk and your tests would still work.
This chapter covers the following topics:
By the end of the chapter, you’ll have seen all the techniques you need for testing Redux.
The code files for this chapter can be found here:
In this section, we’ll do the usual thing of mapping out a rough plan of what we’re going to build.
Let’s start by looking at what the actual technical change is going to be and discuss why we’re going to do it.
We’re going to move the logic for submitting a customer—the doSave function in CustomerForm—out of the React component and into Redux. We’ll use a Redux reducer to manage the status of the operation: whether it’s currently submitting, finished, or had a validation error. We’ll use a Redux saga to perform the asynchronous operation.
Given the current feature set of the application, there’s really no reason to use Redux. However, imagine that in the future, we’d like to support these features:
In this future scenario, it might make sense to have some shared Redux state for the customer data.
I say “might” because there are other, potentially simpler solutions: component context, or perhaps some kind of HTTP response caching. Who knows what the solution would look like? It’s too hard to say without a concrete requirement.
To sum up: in this chapter, we’ll use Redux to store customer data. It has no real benefit over our current approach, and in fact, has the drawback of all the additional plumbing. However, let’s press on, given that the purpose of this book is educational.
A Redux store is simply an object of data with some restrictions on how it is accessed. Here’s how we want ours to look. The object encodes all the information that CustomerForm already uses about a fetch request to save customer data:
{ customer: { status: SUBMITTING | SUCCESSFUL | FAILED | ... // only present if the customer was saved successfully customer: { id: 123, firstName: "Ashley" ... }, // only present if there are validation errors validationErrors: { phoneNumber: "..." }, // only present if there was another type of error error: true | false } }
Redux changes this state by means of named actions. We will have the following actions:
For reference, here’s the existing code that we’ll be extracting from CustomerForm. It’s all helpfully in one function, doSave, even though it is quite long:
const doSave = async () => { setSubmitting(true); const result = await global.fetch("/customers", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify(customer), }); setSubmitting(false); if (result.ok) { setError(false); const customerWithId = await result.json(); onSave(customerWithId); } else if (result.status === 422) { const response = await result.json(); setValidationErrors(response.errors); } else { setError(true); } };
We’ll replace all this code with a combination of a saga and reducer. We’ll start with the reducer, in the next section.
In this section, we’ll test-drive a new reducer function, and then pull out some repeated code.
A reducer is a simple function that takes an action and the current store state as input and returns a new state object as output. Let’s build that now, as follows:
import { reducer } from "../../src/reducers/customer";
describe("customer reducer", () => {
it("returns a default state for an undefined existing state", () => {
expect(reducer(undefined, {})).toEqual({
customer: {},
status: undefined,
validationErrors: {},
error: false
});
});
});
const defaultState = {
customer: {},
status: undefined,
validationErrors: {},
error: false
};
export const reducer = (state = defaultState, action) => {
return state;
};
describe("ADD_CUSTOMER_SUBMITTING action", () => {
const action = { type: "ADD_CUSTOMER_SUBMITTING" };
it("sets status to SUBMITTING", () => {
expect(reducer(undefined, action)).toMatchObject({
status: "SUBMITTING"
});
});
});
switch(action.type) {
case "ADD_CUSTOMER_SUBMITTING":
return { status: "SUBMITTING" };
default:
return state;
}
it("maintains existing state", () => {
expect(reducer({ a: 123 }, action)).toMatchObject({
a: 123
});
});
export const reducer = (state = defaultState, action) => {
switch (action.type) {
case "ADD_CUSTOMER_SUBMITTING":
return { ...state, status: "SUBMITTING" };
default:
return state;
}
};
describe("ADD_CUSTOMER_SUCCESSFUL action", () => {
const customer = { id: 123 };
const action = {
type: "ADD_CUSTOMER_SUCCESSFUL",
customer
};
it("sets status to SUCCESSFUL", () => {
expect(reducer(undefined, action)).toMatchObject({
status: "SUCCESSFUL"
});
});
it("maintains existing state", () => {
expect(
reducer({ a: 123 }, action)
).toMatchObject({ a: 123 });
});
});
case "ADD_CUSTOMER_SUCCESSFUL":
return { ...state, status: "SUCCESSFUL" };
it("sets customer to provided customer", () => {
expect(reducer(undefined, action)).toMatchObject({
customer
});
});
case "ADD_CUSTOMER_SUCCESSFUL":
return {
...state,
status: "SUCCESSFUL",
customer: action.customer
};
describe("ADD_CUSTOMER_FAILED action", () => {
const action = { type: "ADD_CUSTOMER_FAILED" };
it("sets status to FAILED", () => {
expect(reducer(undefined, action)).toMatchObject({
status: "FAILED"
});
});
it("maintains existing state", () => {
expect(
reducer({ a: 123 }, action)
).toMatchObject({ a: 123 });
});
});
case "ADD_CUSTOMER_FAILED":
return { ...state, status: "FAILED" };
it("sets error to true", () => {
expect(reducer(undefined, action)).toMatchObject({
error: true
});
});
case "ADD_CUSTOMER_FAILED":
return { ...state, status: "FAILED", error: true };
describe("ADD_CUSTOMER_VALIDATION_FAILED action", () => {
const validationErrors = { field: "error text" };
const action = {
type: "ADD_CUSTOMER_VALIDATION_FAILED",
validationErrors
};
it("sets status to VALIDATION_FAILED", () => {
expect(reducer(undefined, action)).toMatchObject({
status: "VALIDATION_FAILED"
});
});
it("maintains existing state", () => {
expect(
reducer({ a: 123 }, action)
).toMatchObject({ a: 123 });
});
});
case "ADD_CUSTOMER_VALIDATION_FAILED":
return { ...state, status: "VALIDATION_FAILED" };
it("sets validation errors to provided errors", () => {
expect(reducer(undefined, action)).toMatchObject({
validationErrors
});
});
case "ADD_CUSTOMER_VALIDATION_FAILED":
return {
...state,
status: "VALIDATION_FAILED",
validationErrors: action.validationErrors
};
That completes the reducer, but before we use it from within a saga, how about we dry these tests up a little?
Most reducers will follow the same pattern: each action will set some new data to ensure that the existing state is not lost.
Let’s write a couple of test-generator functions to do that for us, to help us dry up our tests. Proceed as follows:
export const itMaintainsExistingState = (reducer, action) => {
it("maintains existing state", () => {
const existing = { a: 123 };
expect(
reducer(existing, action)
).toMatchObject(existing);
});
};
import {
itMaintainsExistingState
} from "../reducerGenerators";
itMaintainsExistingState(reducer, action);
export const itSetsStatus = (reducer, action, value) => {
it(`sets status to ${value}`, () => {
expect(reducer(undefined, action)).toMatchObject({
status: value
});
});
};
import {
itMaintainsExistingState,
itSetsStatus
} from "../reducerGenerators";
describe("ADD_CUSTOMER_SUBMITTING action", () => {
const action = { type: "ADD_CUSTOMER_SUBMITTING" };
itMaintainsExistingState(reducer, action);
itSetsStatus(reducer, action, "SUBMITTING");
});
That concludes the reducer. Before we move on to the saga, let’s tie it into the application. We won’t make use of it at all, but it’s good to get the plumbing in now.
In addition to the reducer we’ve written, we need to define a function named configureStore that we’ll then call when our application starts. Proceed as follows:
import { createStore, combineReducers } from "redux";
import {
reducer as customerReducer
} from "./reducers/customer";
export const configureStore = (storeEnhancers = []) =>
createStore(
combineReducers({ customer: customerReducer }),
storeEnhancers
);
import { Provider } from "react-redux";
import { configureStore } from "./store";
ReactDOM.createRoot(
document.getElementById("root")
).render(
<Provider store={configureStore()}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
);
With that in place, we’re ready to write the tricky part: the saga.
A saga is a special bit of code that uses JavaScript generator functions to manage asynchronous operations to the Redux store. Because it’s super complex, we won’t actually test the saga itself; instead, we’ll dispatch an action to the store and observe the results.
Before we get started on the saga tests, we need a new test helper function named renderWithStore.
import { Provider } from "react-redux";
import { storeSpy } from "expect-redux";
import { configureStore } from "../src/store";
The expect-redux package
For that, we’ll use the expect-redux package from NPM, which has already been included in the package.json file for you—make sure to run npm install before you begin.
export let store;
export const initializeReactContainer = () => {
store = configureStore([storeSpy]);
container = document.createElement("div");
document.body.replaceChildren(container);
reactRoot = ReactDOM.createRoot(container);
};
export const renderWithStore = (component) =>
act(() =>
reactRoot.render(
<Provider store={store}>{component}</Provider>
)
);
export const dispatchToStore = (action) =>
act(() => store.dispatch(action));
You’ve now got all the helpers you need to begin testing both sagas and components that are connected to a Redux store. With all that in place, let’s get started on the saga tests.
The saga we’re writing will respond to an ADD_CUSTOMER_REQUEST action that’s dispatched from the CustomerForm component when the user submits the form. The functionality of the saga is just the same as the doSave function listed in the Designing the store state and actions section at the beginning of this chapter. The difference is we’ll need to use the saga’s function calls of put, call, and so forth.
Let’s begin by writing a generator function named addCustomer. Proceed as follows:
import { storeSpy, expectRedux } from "expect-redux";
import { configureStore } from "../../src/store";
describe("addCustomer", () => {
let store;
beforeEach(() => {
store = configureStore([ storeSpy ]);
});
});
const addCustomerRequest = (customer) => ({
type: "ADD_CUSTOMER_REQUEST",
customer,
});
it("sets current status to submitting", () => {
store.dispatch(addCustomerRequest());
return expectRedux(store)
.toDispatchAnAction()
.matching({ type: "ADD_CUSTOMER_SUBMITTING" });
});
Returning promises from tests
This test returns a promise. This is a shortcut we can use instead of marking our test function as async and the expectation with await. Jest knows to wait if the test function returns a promise.
import { put } from "redux-saga/effects";
export function* addCustomer() {
yield put({ type: "ADD_CUSTOMER_SUBMITTING" });
}
Generator-function syntax
The arrow-function syntax that we’ve been using throughout the book does not work for generator functions, so we need to fall back to using the function keyword.
import {
createStore,
applyMiddleware,
compose,
combineReducers
} from "redux";
import createSagaMiddleware from "redux-saga";
import { takeLatest } from "redux-saga/effects";
import { addCustomer } from "./sagas/customer";
import {
reducer as customerReducer
} from "./sagas/customer";
function* rootSaga() {
yield takeLatest(
"ADD_CUSTOMER_REQUEST",
addCustomer
);
}
export const configureStore = (storeEnhancers = []) => {
const sagaMiddleware = createSagaMiddleware();
const store = createStore(
combineReducers({ customer: customerReducer }),
compose(
applyMiddleware(sagaMiddleware),
...storeEnhancers
)
);
sagaMiddleware.run(rootSaga);
return store;
};
That completes the first test for the saga, and gets all the necessary plumbing into place. You’ve also seen how to use put. Next up, let’s introduce call.
Within a saga, call allows us to perform an asynchronous request. Let’s introduce that now. Follow these steps:
it("sends HTTP request to POST /customers", async () => {
const inputCustomer = { firstName: "Ashley" };
store.dispatch(addCustomerRequest(inputCustomer));
expect(global.fetch).toBeCalledWith(
"/customers",
expect.objectContaining({
method: "POST",
})
);
});
beforeEach(() => {
jest.spyOn(global, "fetch");
store = configureStore([ storeSpy ]);
});
import { put, call } from "redux-saga/effects";
const fetch = (url, data) =>
global.fetch(url, {
method: "POST",
});
export function* addCustomer({ customer }) {
yield put({ type: "ADD_CUSTOMER_SUBMITTING" });
yield call(fetch, "/customers", customer);
}
it("calls fetch with correct configuration", async () => {
const inputCustomer = { firstName: "Ashley" };
store.dispatch(addCustomerRequest(inputCustomer));
expect(global.fetch).toBeCalledWith(
expect.anything(),
expect.objectContaining({
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
})
);
});
const fetch = (url, data) =>
global.fetch(url, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" }
});
it("calls fetch with customer as request body", async () => {
const inputCustomer = { firstName: "Ashley" };
store.dispatch(addCustomerRequest(inputCustomer));
expect(global.fetch).toBeCalledWith(
expect.anything(),
expect.objectContaining({
body: JSON.stringify(inputCustomer),
})
);
});
const fetch = (url, data) =>
global.fetch(url, {
body: JSON.stringify(data),
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" }
});
it("dispatches ADD_CUSTOMER_SUCCESSFUL on success", () => {
store.dispatch(addCustomerRequest());
return expectRedux(store)
.toDispatchAnAction()
.matching({
type: "ADD_CUSTOMER_SUCCESSFUL",
customer
});
});
const customer = { id: 123 };
beforeEach(() => {
jest
.spyOn(global, "fetch")
.mockReturnValue(fetchResponseOk(customer));
store = configureStore([ storeSpy ]);
});
import { fetchResponseOk } from "../builders/fetch";
export function* addCustomer({ customer }) {
yield put({ type: "ADD_CUSTOMER_SUBMITTING" });
const result = yield call(fetch, "/customers", customer);
const customerWithId = yield call([result, "json"]);
yield put({
type: "ADD_CUSTOMER_SUCCESSFUL",
customer: customerWithId
});
}
it("dispatches ADD_CUSTOMER_FAILED on non-specific error", () => {
global.fetch.mockReturnValue(fetchResponseError());
store.dispatch(addCustomerRequest());
return expectRedux(store)
.toDispatchAnAction()
.matching({ type: "ADD_CUSTOMER_FAILED" });
});
import {
fetchResponseOk,
fetchResponseError
} from "../builders/fetch";
export function* addCustomer({ customer }) {
yield put({ type: "ADD_CUSTOMER_SUBMITTING" });
const result = yield call(
fetch,
"/customers",
customer
);
if(result.ok) {
const customerWithId = yield call(
[result, "json"]
);
yield put({
type: "ADD_CUSTOMER_SUCCESSFUL",
customer: customerWithId
});
} else {
yield put({ type: "ADD_CUSTOMER_FAILED" });
}
}
it("dispatches ADD_CUSTOMER_VALIDATION_FAILED if validation errors were returned", () => {
const errors = {
field: "field",
description: "error text"
};
global.fetch.mockReturnValue(
fetchResponseError(422, { errors })
);
store.dispatch(addCustomerRequest());
return expectRedux(store)
.toDispatchAnAction()
.matching({
type: "ADD_CUSTOMER_VALIDATION_FAILED",
validationErrors: errors
});
});
export function* addCustomer({ customer }) {
yield put({ type: "ADD_CUSTOMER_SUBMITTING" });
const result = yield call(fetch, "/customers", customer);
if(result.ok) {
const customerWithId = yield call(
[result, "json"]
);
yield put({
type: "ADD_CUSTOMER_SUCCESSFUL",
customer: customerWithId
});
} else if (result.status === 422) {
const response = yield call([result, "json"]);
yield put({
type: "ADD_CUSTOMER_VALIDATION_FAILED",
validationErrors: response.errors
});
} else {
yield put({ type: "ADD_CUSTOMER_FAILED" });
}
}
The saga is now complete. Compare this function to the function in CustomerForm that we’re replacing: doSave. The structure is identical. That’s a good indicator that we’re ready to work on removing doSave from CustomerForm.
In the next section, we’ll update CustomerForm to make use of our new Redux store.
The saga and reducer are now complete and ready to be used in the CustomerForm React component. In this section, we’ll replace the use of doSave, and then as a final flourish, we’ll push our React Router navigation into the saga, removing the onSave callback from App.
At the start of the chapter, we looked at how the purpose of this change was essentially a transplant of CustomerForm’s doSave function into a Redux action.
With our new Redux setup, we used component state to display a submitting indicator and show any validation errors. That information is now stored within the Redux store, not component state. So, in addition to dispatching an action to replace doSave, the component also needs to read state from the store. The component state variables can be deleted.
This has a knock-on effect on our tests. Since the saga tests the failure modes, our component tests for CustomerForm simply need to handle various states of the Redux store, which we’ll manipulate using our dispatchToStore extension.
We’ll start by making our component Redux-aware, as follows:
import { expectRedux } from "expect-redux";
import {
initializeReactContainer,
renderWithStore,
dispatchToStore,
store,
...
} from "./reactTestExtensions";
it("dispatches ADD_CUSTOMER_REQUEST when submitting data", async () => {
renderWithStore(
<CustomerForm {...validCustomer} />
);
await clickAndWait(submitButton());
return expectRedux(store)
.toDispatchAnAction()
.matching({
type: 'ADD_CUSTOMER_REQUEST',
customer: validCustomer
});
});
const handleSubmit = async (event) => {
event.preventDefault();
const validationResult = validateMany(
validators, customer
);
if (!anyErrors(validationResult)) {
await doSave();
dispatch(addCustomerRequest(customer));
} else {
setValidationErrors(validationResult);
}
};
import { useDispatch } from "react-redux";
const dispatch = useDispatch();
const addCustomerRequest = (customer) => ({
type: "ADD_CUSTOMER_REQUEST",
customer,
});
At this point, your component is now Redux-aware, and it’s dispatching the right action to Redux. The remaining work is to modify the component to deal with validation errors coming from Redux rather than the component state.
Now, it’s time to introduce the useSelector hook to pull out state from the store. We’ll kick things off with the ADD_CUSTOMER_FAILED generic error action. Recall that when the reducer receives this, it updates the error store state value to true. Follow these steps:
it("renders error message when error prop is true", () => {
renderWithStore(
<CustomerForm {...validCustomer} />
);
dispatchToStore({ type: "ADD_CUSTOMER_FAILED" });
expect(element("[role=alert]")).toContainText(
"error occurred"
);
});
import {
useDispatch,
useSelector
} from "react-redux";
const {
error,
} = useSelector(({ customer }) => customer);
it("does not submit the form when there are validation errors", async () => {
renderWithStore(
<CustomerForm original={blankCustomer} />
);
await clickAndWait(submitButton());
return expectRedux(store)
.toNotDispatchAnAction(100)
.ofType("ADD_CUSTOMER_REQUEST");
});
The toNotDispatchAnAction matcher
This matcher should always be used with a timeout, such as 100 milliseconds in this case. That’s because, in an asynchronous environment, events may just be slow to occur, rather than not occurring at all.
it("renders field validation errors from server", () => {
const errors = {
phoneNumber: "Phone number already exists in the system"
};
renderWithStore(
<CustomerForm {...validCustomer} />
);
dispatchToStore({
type: "ADD_CUSTOMER_VALIDATION_FAILED",
validationErrors: errors
});
expect(
errorFor(phoneNumber)
).toContainText(errors.phoneNumber);
});
So, let’s rename the prop we get back from the server, like so:
const {
error,
validationErrors: serverValidationErrors,
} = useSelector(({ customer }) => customer);
A design issue
This highlights a design issue in our original code. The validationErrors state variable had two uses, which were mixed up. Our change here will separate those uses.
const renderError = fieldName => {
const allValidationErrors = {
...validationErrors,
...serverValidationErrors
};
return (
<span id={`${fieldname}error`} role="alert">
{hasError(allValidationErrors, fieldName)
? allValidationErrors[fieldname]
: ""}
</span>
);
};
it("displays indicator when form is submitting", () => {
renderWithStore(
<CustomerForm {...validCustomer} />
);
dispatchToStore({
type: "ADD_CUSTOMER_SUBMITTING"
});
expect(
element(".submittingIndicator")
).not.toBeNull();
});
const {
error,
status,
validationErrors: serverValidationErrors,
} = useSelector(({ customer }) => customer);
const submitting = status === "SUBMITTING";
it("hides indicator when form has submitted", () => {
renderWithStore(
<CustomerForm {...validCustomer} />
);
dispatchToStore({
type: "ADD_CUSTOMER_SUCCESSFUL"
});
expect(element(".submittingIndicator")).toBeNull();
});
That’s it for test changes, and doSave is almost fully redundant. However, the call to onSave still needs to be migrated across into the Redux saga, which we’ll do in the next section.
Recall that it is the App component that renders CustomerForm, and App passes a function to the CustomerForm’s onSave prop that causes page navigation. When the customer information has been submitted, the user is moved onto the /addAppointment route.
But now that the form submission happens within a Redux saga, how do we call the onSave prop? The answer is that we can’t. Instead, we can move page navigation into the saga itself and delete the onSave prop entirely.
To do this, we must update src/index.js to use HistoryRouter rather than BrowserRouter. That allows you to pass in your own history singleton object, which you can then explicitly construct yourself and then access via the saga. Proceed as follows:
import { createBrowserHistory } from "history";
export const appHistory = createBrowserHistory();
import React from "react";
import ReactDOM from "react-dom/client";
import { Provider } from "react-redux";
import {
unstable_HistoryRouter as HistoryRouter
} from "react-router-dom";
import { appHistory } from "./history";
import { configureStore } from "./store";
import { App } from "./App";
ReactDOM.createRoot(
document.getElementById("root")
).render(
<Provider store={configureStore()}>
<HistoryRouter history={appHistory}>
<App />
</HistoryRouter>
</Provider>
);
import { appHistory } from "../../src/history";
it("navigates to /addAppointment on success", () => {
store.dispatch(addCustomerRequest());
expect(appHistory.location.pathname).toEqual(
"/addAppointment"
);
});
it("includes the customer id in the query string when navigating to /addAppointment", () => {
store.dispatch(addCustomerRequest());
expect(
appHistory.location.search
).toEqual("?customer=123");
});
import { appHistory } from "../history";
export function* addCustomer({ customer }) {
...
yield put({
type: "ADD_CUSTOMER_SUCCESSFUL",
customer: customerWithId,
});
appHistory.push(
`/addAppointment?customer=${customerWithId.id}`
);
}
<Route
path="/addCustomer"
element={<CustomerForm original={blankCustomer} />}
/>
You’ve now seen how you can integrate a Redux store into your React components, and how you can control React Router navigation from within a Redux saga.
All being well, your application should now be running with Redux managing the workflow.
This has been a whirlwind tour of Redux and how to refactor your application to it, using TDD.
As warned in the introduction of this chapter, Redux is a complex library that introduces a lot of extra plumbing into your application. Thankfully, the testing approach is straightforward.
In the next chapter, we’ll add yet another library: Relay, the GraphQL client.
For more information, have a look at the following sources:
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/function*