Advanced Unit Testing in React with Jest: Hooks and Promises

Advanced Unit Testing in React with Jest: Hooks and Promises

Emmanuel Byrd
Emmanuel Byrd

January 06, 2023

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

Image1

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.