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 themockData
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.
Top comments (0)