Unit testing is essential in every codebase, and React is no exception. However, as the code becomes more complex, patterns and challenges are faced that go beyond the conventional cases. I have found that creating these specific unit tests allowed me to get a better understanding of how React works under the hood.
In this article, I will share a couple of useful patterns for advanced unit testing in React, namely the cases of testing a custom React hook and testing state through asynchronous calls in our code (promises). This post assumes some previous experience with unit testing and the React framework.
Testing React Hooks
My first challenge was creating unit tests for a custom React hook. This hook executes an API call and returns an object with different values, depending on the call. One of those values is a loading
flag. I needed to assert that such a flag is true
while waiting for a response, and is set to false
whether the call succeeded or not. The rest of the returned object contains a field for the value of a successful call and another field for the reason of a failure.
In this example, I executed a call to the dog-api
, where I requested an arbitrary number of random facts about dogs. The API uses a JSON format for its responses, so I need to call response.json()
, adding an extra promise that I will need to handle in the tests.
// blog.tsx
import { useEffect, useState } from 'react';
interface HookResponse {
loading: boolean;
messages: string[];
isError: boolean;
}
export function useHookWithFetch(number: number): HookResponse {
const [loading, setLoading] = useState(false);
const [messages, setMessages] = useState([]);
const [isError, setIsError] = useState(false);
useEffect(() => {
if (!number) {
// do not execute if 0
return;
}
setMessages([]);
setIsError(false);
setLoading(true);
// --- Check out the Dog API at https://kinduff.github.io/dog-api/ ---
// Found at https://github.com/public-apis/public-apis#animals
// Response example:
// {
// "facts": [
// "Dogs have twice as many muscles to move their ears as people."
// ],
// "success": true
// }
fetch(`https://dog-api.kinduff.com/api/facts?number=${number}`)
.then((response) => response.json())
.then((data) => {
setLoading(false);
setMessages(data.facts);
})
.catch((reason) => {
setLoading(false);
setIsError(true);
});
}, [number]);
const returnValues: HookResponse = {
loading,
messages,
isError,
};
return returnValues;
}
Start Testing a React Hook
The first challenge is to call this custom hook in unit tests. All React hooks need to be called in the context of a React component, and I needed to directly assert whatever values were returned from it. A useful pattern is to create a dummy component in the tests that will use the hook, and add a callback function on that component that will be called every time there is a change in the hook’s returned value:
// blog.spec.tsx
interface TestOnlyComponentProps {
number: number;
updatesCallback: jest.Mock;
}
function TestOnlyComponent({
number,
updatesCallback,
}: TestOnlyComponentProps) {
const returnValues = useHookWithFetch(number);
useEffect(() => {
if (updatesCallback) {
updatesCallback(returnValues);
}
}, [returnValues, updatesCallback]);
return Fake Test Component;
}
The useHookWithFetch
is the actual hook that I wanted to test, but given the constraints, I needed to jump through hoops to create the right environment. The TestOnlyComponent
received the number
of random facts that passed to the dog-api, and received an updatesCallback
that will be executed with whatever the result of the hook may be, every time that it changes. The return value <div>Fake Test Component</div>
is inconsequential.
At this point, I am able to test the hook, but it will actually call the dog-api, which is not the pattern that I want when unit testing. I need to mock that call. By mocking the fetch call, I will have complete control over the different states of the call, and will be able to assert accordingly.
Mocking a Fetch Call
The unit test is aiming to check the behavior of the global fetch
function. In order to prevent the test from actually sending calls to the live system, a useful pattern is to substitute it by a jest function. Further still, it will return a promise that is neither rejected nor resolved, and it will have the handlers available to reject or resolve exactly when I want it to.
describe('test useHookWithFetch', () => {
const originalFetch = global.fetch;
const fetchSpy = jest.fn().mockName('fetch');
global.fetch = fetchSpy;
function newFetchSpyPromiseHandlers() {
let fetchResolve: (response: Response) => void;
let fetchReject: (value: string) => void;
// Build on-demand resolution and rejection of `fetch`
const fetchPromise = new Promise((resolve, reject) => {
fetchResolve = resolve;
fetchReject = reject;
});
fetchSpy.mockReturnValueOnce(fetchPromise);
return { fetchResolve, fetchReject };
}
// Reset the call registry of fetchSpy
beforeEach(() => {
fetchSpy.mockClear();
// or jest.clearAllMocks();
});
// Restore the original fetch function
afterAll(() => {
global.fetch = originalFetch;
});
// Unit tests here ...
});
The function newFetchSpyPromiseHandlers
will mock a returned value for the fetch call once. Additionally, the fetchSpy.mockClear
will prevent mocks from one test to leak to another, and keeps the test suite clean. The return value of the newFetchSpyPromiseHandlers
is an object that contains two functions: fetchResolve
and fetchReject
. I can call any of these functions inside a unit test, and it will be exactly at that moment when the promise will resolve. As you might expect, if I don’t call any of those functions, I would expect the hook to stay in the loading
state.
Mocking a Response.json Promise
Following the same idea of mocking a fetch call, I need to mock the response.json()
promise that is called on a successful response by the dog-api. Inside the same describe
block, I can add this useful pattern to give me detailed control on this second promise.
import 'whatwg-fetch';
describe('test useHookWithFetch', () => {
// fetchSpy ...
interface FactsData {
facts: string[];
success: boolean;
}
function newBlobResponse() {
const response = new Response(); // whatwg-fetch
// Build on-demand resolution and rejection of response.json()
let jsonResolve: (object: FactsData) => void;
let jsonReject: (value: string) => void;
jest.spyOn(response, 'json').mockReturnValue(
new Promise((resolve, reject) => {
jsonResolve = resolve;
jsonReject = reject;
})
);
return {
response,
jsonResolve,
jsonReject,
};
}
// beforeEach ...
// afterAll ...
// ...
To create a new response, I can install whatwg-fetch
. This object contains the .json()
method that returns a promise. Another alternative might be to create such a class, but this way I keep type safety given that I am using TypeScript, and delegate some complexity along the way. Now, instead of spying on a global variable, I spy on the created response
object, and return a pending promise and its handlers, just as I did with the fetch
spy.
Both the fetch mock and the response mock will come together later in this post.
Early Refactoring
I created a function that renders a test component, because that component will need to be rendered in multiple unit tests. The updatesCallback
that I need to send to the test component will be yet another jest function. Providing a jest function allows me to assert things like expect.toHaveBeenCalledTimes
and expect.toHaveBeenCalledWith
on it — which will be helpful later on. The following code exists still inside the main describe
block.
const updatesCallback = jest.fn().mockName('updatesCallback');
function renderHook(number: number) {
act(() => {
render(
);
});
}
// Reset the call registry of fetch Spy and updatesCallback
beforeEach(() => {
updatesCallback.mockClear(); // Add this line
fetchSpy.mockClear();
// or jest.clearAllMocks();
});
The code needs to be wrapped in an act
block because as soon as it is rendered, it will start calling the API and execute state updates. I will pass the number
of random facts that I want the API to fetch as the only parameter of the renderHook
function.
First Unit Test
After all the setup, I personally like to start the test suite with the simplest case. This ensures all the code is compiling correctly, and I have built the setup correctly too.
import '@testing-library/jest-dom'; // expect().toBeInTheDocument
// describe( ...
it('renders component', async () => {
renderHook(0);
expect(fetchSpy).not.toHaveBeenCalled();
// The following line only with '@testing-library/jest-dom':
expect(screen.getByText('Fake Test Component')).toBeInTheDocument();
});
Note that in order to have the function .toBeInTheDocument()
, I need to install @testing-library/jest-dom
.
In this specific test, the fetchSpy
is not called because I rendered the component with 0
expected facts, and the hook returns without calling the dog-api if that is the case.
Asserting the Loading State
Things start to get complex now. The next unit test will assert that the hook returns a loading: true
value if the fetch call is executed, but before it is finished. First, I need to mock the fetch function, then render the test component asking for 1
random fact, and finally assert the state of the updatesCallback
and the fetchSpy
.
it('loading is true while awaiting', async () => {
newFetchSpyPromiseHandlers(); // Prevent real fetch calls, even if we don’t use its return values
renderHook(1);
expect(updatesCallback).toHaveBeenCalledWith({
loading: true,
messages: [],
isError: false,
});
// The hook will be waiting for fetch to either resolve or reject
expect(fetchSpy).toHaveBeenCalledWith(
'https://dog-api.kinduff.com/api/facts?number=1' // <- number=1
);
});
The hook successfully created the right URI for the fetch call, by adding the number 1
to the query parameter ?number=
.
Adding yet a bit more complexity, I create a unit test that ensures the loading state is false when the number is 0
, but is then true if that same parameter is updated to 1
. By getting the container
from the result value of render
, I can trigger an update of any component, instead of rendering a new one.
it('starts loading when `number` prop is updated', async () => {
newFetchSpyPromiseHandlers(); // Prevent real fetch calls
let testContainer: HTMLElement;
await act(async () => {
const { container } = render(
);
testContainer = container;
});
expect(updatesCallback).toHaveBeenCalledTimes(1);
expect(updatesCallback).toHaveBeenCalledWith({
loading: false,
messages: [],
isError: false,
});
// Rendering in the same container will re-render instead of mounting
await act(async () => {
render(
,
{ container: testContainer }
);
});
expect(updatesCallback).toHaveBeenCalledTimes(3);
// Note: to have been LAST called with...
expect(updatesCallback).toHaveBeenLastCalledWith(
expect.objectContaining({
loading: true,
})
);
});
This last test ensures the loading
flag will only be true
when the number of requested facts is not zero, and it works as it should in React: by updating one of the parameters of the component. Remember that this parameter is sent directly to the hook, which executes a call because it has a useEffect
that triggers on any change from its number
parameter:
export function useHookWithFetch(number: number): HookResponse {
// ...
useEffect(() => {
// ...
}, [number]); // `number` changed from 0 -> 1
Testing a Chain of Promises
Now that I have tested that I have control over the fetch promise, I can resolve it with a Response
object, and continue testing that the loading
state will continue being true
before that second promise resolves.
it('loading and messages are updated upon resolution', async () => {
const { fetchResolve } = newFetchSpyPromiseHandlers();
renderHook(42);
expect(updatesCallback).toHaveBeenCalledWith({
loading: true,
messages: [],
isError: false,
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
const responseObject = {
facts: ['Max, Jake, Maggie and Molly are the most popular dog names.'],
success: true,
};
await act(async () => {
const { response, jsonResolve } = newBlobResponse();
fetchResolve(response);
jsonResolve(responseObject);
});
expect(updatesCallback).toHaveBeenLastCalledWith({
loading: false,
messages: ['Max, Jake, Maggie and Molly are the most popular dog names.'],
isError: false,
});
});
I first get the handlers of the fetch promise, and will keep only the fetchResolve
function. After asserting that the hook return value is loading: true
, I create a newBlobResponse
object, which is in turn returning an unresolved promise with its handlers. The fetch
call is resolved with the new response
promise. Afterwards, the .json()
promise is resolved with the new responseObject
. After resolving the .json()
call, I expect the hook to send the retrieved message to the callback function, which is being asserted in the end. Note that the updatesCallback
was last called with loading: false
, besides the provided messages array.
Asserting Promises Rejection
It should be easier now to design a test that will assert what I expect to happen if either the fetch
or the .json
promises are rejected. Due to how I chained both promises in the custom hook, it doesn’t matter which of them is rejected, I expect the same behavior. First I’ll design the case where the first promise, the fetch
, is rejected.
it('loading and error are updated upon fetch rejection', async () => {
const { fetchReject } = newFetchSpyPromiseHandlers();
renderHook(2);
expect(updatesCallback).toHaveBeenCalledWith({
loading: true,
messages: [],
isError: false,
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
await act(async () => {
fetchReject('some absurd reason');
});
expect(updatesCallback).toHaveBeenLastCalledWith({
loading: false,
messages: [],
isError: true,
});
});
And then, in order to test the rejection of the second promise, the .json
, I need to successfully resolve the first one.
it('loading and error are updated upon .json rejection', async () => {
const { fetchResolve } = newFetchSpyPromiseHandlers();
renderHook(8);
expect(updatesCallback).toHaveBeenCalledWith({
loading: true,
messages: [],
isError: false,
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
await act(async () => {
const { response, jsonReject } = newBlobResponse();
fetchResolve(response);
jsonReject('malformed JSON');
});
expect(updatesCallback).toHaveBeenLastCalledWith({
loading: false,
messages: [],
isError: true,
});
});
Note that the updatesCallback
was last called with isError: true
in both tests, which is exactly what I expected the hook to do.
React Insights
Given how I’ve designed these tests, I can get some information about how React is working internally. It was interesting to me to note that the callback function was executed once per action, even though there are many useState
hooks in play. At first, I was expecting one callback per useState
update, but that is not the case. It seems that React is throttling state updates by default.
I also note that React keeps track of containers, so that child components are rerendered, instead of mounted, when any of its parameters changes. This prevents the child component state from falling back to its default value.
Next Steps
Even after taking a glance at React internals and designing these specific tests, some questions remain unanswered. For example, I am always calling newFetchSpyPromiseHandlers
at the beginning of each test, but my attempts to refactor them into a beforeEach
block were futile. How might I refactor them?
With regards to the new Response()
object, created through whatwg-fetch
, it seems that I don’t need all of its functionality. I just need to return an object with the .json
function that will return a promise, and a way for me to keep type safety. How might I do that, simplifying the code?
There is one assertion that I purposely didn’t explain. In the test named starts loading when ‘number’ prop is updated
, the number of times the updatesCallback
was called jumps from 1
to 3
after rerendering the component. Why is the rerendering triggering two new updateCallbacks
calls instead of one?
And lastly, these tests use the synchronous version of act
: await act(async () => { … })
, and they won’t pass if I use its asynchronous version: act(() => { … })
, even if I am resolving the promises inside them. Why is the synchronous version of act
needed for the tests to pass?
Digging deeper into these questions might help expose some more ways that you can leverage React's features to compose advanced unit tests.