Efficiently Testing Asynchronous React Hooks with Vitest
Tags: react, testing, frontend
Published: 2024-04-10 20:59:20

## 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](https://vitest.dev/guide/): our main testing framework
    
* [JSDOM](https://www.npmjs.com/search?q=jsdom): DOM environment for running our tests
    
* [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/): provides utilities to make testing easier
    
* [MSW](https://mswjs.io/docs): library to mock API calls for the tests.
    

To do so, we run the following command:

```powershell
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:

```javascript
export default defineConfig({
  //...
  test: {
    global: true,
    environment: 'jsdom',
  },
})
```

Now we can run our test with `npx vitest` .

## The `useApi` Hook

```javascript
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:

```javascript
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`](https://testing-library.com/docs/react-testing-library/api/#renderhook) returns an object instance containing `result` property from which we can access the hook's return value.

```javascript
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 the**`callApi` 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:

```javascript
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:

```javascript
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:

```javascript
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](https://www.linkedin.com/in/alhazan-mubarak/) and subscribe to my [YouTube Channel](https://www.youtube.com/@poly4) where I plan to share more valuable content. Also, Let me know your thoughts in the comment section.