Efficiently Testing Asynchronous React Hooks with Vitest

Rmag Breaking News

Introduction

While working on a fairly large frontend project, a seemingly minor change in the backend’s error message structure triggered a cascade of changes across numerous files on the front end, making it a notably hard task to rectify. Determined to spare myself and my team from such headaches in the future, I created a custom useApi hook. This hook was tailored for handling API calls throughout the application, ensuring that any future changes to data structure could be managed from a single file.

Realizing the pivotal role of this hook across the codebase, the necessity of writing robust tests became apparent, In this article, we’ll walk through the process of efficiently testing asynchronous React Hooks, drawing insights from my experience with this useApi hook.

Dependencies

We should have a React project set up and running. We can initialize the project with Vite using the command npm create vite@latest.

To replicate this test, we need to install the following dependencies:

Vitest: our main testing framework

JSDOM: DOM environment for running our tests

React Testing Library: provides utilities to make testing easier

MSW: library to mock API calls for the tests.

To do so, we run the following command:

npm install -D vitest jsdom @testing-library/react msw
#OR
yarn add -D vitest jsdom @testing-library/react msw

In vitest.config.js (or vite.config.js for Vite projects), we add the following test object:

export default defineConfig({
//…
test: {
global: true,
environment: jsdom,
},
})

Now we can run our test with npx vitest .

The useApi Hook

import { useState } from react;
import axios from axios;

export const useApi = (url, method, body, headers) => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(“”);

const callApi = async () => {
setLoading(true);
try {
const response = await axios({ url, method, body, headers });
if (response.data === null || response.data === undefined) {
setData([]);
}
setData(response.data);
} catch (error) {
setError(error.message);
}
setLoading(false);
};

return [data, loading, error, callApi];
};

The hook is designed to handle API calls within the application and offers flexibility to adapt to various API scenarios. It accepts the following parameters: url , method, body , and headers .

The hook uses axios to execute the API call to the specified URL which handles gracefully cases when certain parameters like the request body or headers are not provided.

The useApi hook returns an array containing the fetched data, a boolean flag indicating the loading state, any encountered errors, and a function callApi to initiate the API request.

A sample usage of this hook to make a POST request will look like this:

const [createResponse, creating, createError, createRecord] = useApi(
https://jsonplaceholder.typicode.com/posts, POST, body
);

In this example, createResponse holds the response data from the API call, creating indicates whether the request is currently in progress, createError captures any errors encountered during the request, and createRecord is the function to initiate the API call.

By encapsulating API logic within the reusable useApi hook, we can enhance code maintainability, improve readability, and ensure consistent handling of asynchronous operations throughout our application.

Now let’s test 🪄:

Testing the Hook

We will test a scenario when the useApi makes a successful GET request.

Default Value Test

We start by testing the default return value of the useApi hook. Using renderHook from @testing-library/react. renderHook returns an object instance containing result property from which we can access the hook’s return value.

import { renderHook} from @testing-library/react;
import { useApi } from ./useApi;

describe(useApi, () => {
test(should fetch data on callApi for GET request, async () => {
const { result } = renderHook(() =>
useApi(https://api.example.com/items, GET)
);

expect(result.current[0]).toBe(null); // Data should be null initially
expect(result.current[1]).toBe(false); // Loading state should be false initially
expect(result.current[2]).toEqual(“”); // Error should be an empty string initially
});

})

Testing thecallApi Function

Next, we test the behaviour when the callApi function is invoked. We use act to simulate the function call and waitFor to await its asynchronous result. Here’s the test case:

import { act, renderHook, waitFor } from @testing-library/react;
import { useApi } from ./useApi;

const mockData = [
{ id: 1, item: Leanne Graham },
{ id: 2, item: Ervin Howell },
];

describe(useApi, () => {
test(should fetch data on callApi for GET request, async () => {
const { result } = renderHook(() =>
useApi(https://api.example.com/items, GET)
);

expect(result.current[0]).toBe(null);
expect(result.current[1]).toBe(false);
expect(result.current[2]).toEqual(“”);

act(() => {
result.current[3]();// Invoke callApi function
});

expect(result.current[1]).toBe(true); // Loading state should be true during API call
await waitFor(() => {
// Wait for API call to complete
expect(result.current[1]).toBe(false); // Loading state should be false after API call
expect(result.current[0]).toEqual(mockData); // Data should match mock data
});
});
})

In the code above, we used waitFor to await the result of the callApi function because it is an asynchronous function. waitFor accepts a callback and returns a Promise that resolves when the callback executes successfully.

Mocking the Api Call

In the above test scenarios, we directly called the API, which is not ideal for unit tests as it can lead to dependencies on external services and unpredictable test outcomes. Instead, we should mock the API call to isolate the behaviour of the hook and ensure reliable and consistent testing.

Using MSW for API Mocking

To mock the GET request, we’ll define a mock handler using MSW like this:

import { afterAll, afterEach, beforeAll /**… */ } from vitest;
import { HttpResponse, http } from msw;
import { setupServer } from msw/node;

const mockData = [
{ id: 1, item: Leanne Graham },
{ id: 2, item: Ervin Howell },
];

const handlers = [
http.get(https://api.example.com/items, () => {
return HttpResponse.json(mockData);
})
];

// Set up the mock server with the defined handlers
const server = setupServer(…handlers);

describe(useApi, () => {
// Start the mock server before running the tests
beforeAll(() => server.listen({ onUnhandledRequest: error }));
// Reset mock server handlers after each test
afterEach(() => server.resetHandlers());
// Close the mock server after all tests have run
afterAll(() => server.close());

/** Tests will go here **/
})

In the above code:

We define a mock handler using http.get() from MSW. This handler intercepts GET requests and responds with the mockData

We then set up a mock server using setupServer() from MSW and pass the defined handlers to it.

Just before running the tests, we start the mock server (server.listen()), ensuring that it intercepts requests during test execution.

After each test, we reset the mock server handlers to ensure a clean state for the next test.

Finally, after all tests have run, we close the mock server to clean up resources (server.close()).

And that’s it, we can test the hook now.

Other Scenarios

Using a similar structure, we can write a test for other scenarios for the useApi hook. Let’s consider a scenario where a POST request fails, such as due to authentication issues:

import { afterAll, afterEach, beforeAll, describe, expect, test } from vitest;
import { HttpResponse, http } from msw;
import { setupServer } from msw/node;
import { act, renderHook, waitFor } from @testing-library/react;
import { useApi } from ./useApi;

const handlers = [
//other handlers…
http.post(https://api.example.com/login, ({ request }) => {
if (!request.headers.has(cookie)) {
throw new HttpResponse(null, { status: 401 });
}
}),
];
const server = setupServer(…handlers);

describe(useApi, () => {
beforeAll(() => server.listen({ onUnhandledRequest: error }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test(post request error, async () => {
const body = { name: test, password: test };
const { result } = renderHook(() =>
useApi(https://api.example.com/login, post, body)
);

expect(result.current[0]).toBe(null);
expect(result.current[1]).toBe(false);
expect(result.current[2]).toEqual(“”);

act(() => {
result.current[3]();
});

expect(result.current[1]).toBe(true);
await waitFor(() => {
expect(result.current[1]).toBe(false);
// Expect error message
expect(result.current[2]).toEqual(Request failed with status code 401);
expect(result.current[0]).toEqual(null);
});
});
})

Conclusion

In this article, we learnt how to test asynchronous React hooks using the React Testing Library and Vitest package. We also learn how to mock requests using the MSW package.

Thank you for reading

I appreciate the time we spent together. I hope this content will be more than just text. Follow me on Linkedin and subscribe to my YouTube Channel where I plan to share more valuable content. Also, Let me know your thoughts in the comment section.

Leave a Reply

Your email address will not be published. Required fields are marked *