DEV Community

Cover image for Efficiently Testing Asynchronous React Hooks with Vitest
Mubarak Alhazan
Mubarak Alhazan

Posted on • Originally published at poly4.hashnode.dev

Efficiently Testing Asynchronous React Hooks with Vitest

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
Enter fullscreen mode Exit fullscreen mode

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',
  },
})
Enter fullscreen mode Exit fullscreen mode

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];
};
Enter fullscreen mode Exit fullscreen mode

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
  );
Enter fullscreen mode Exit fullscreen mode

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
    });

})
Enter fullscreen mode Exit fullscreen mode

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
        });
    });
})
Enter fullscreen mode Exit fullscreen mode

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 **/
})
Enter fullscreen mode Exit fullscreen mode

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);
    });
  });
})
Enter fullscreen mode Exit fullscreen mode

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)