React-Query useMutation with Jest Testing

Introduction

React-Query is a powerful tool for fetching/caching the data on the frontend side, yet testing React-Query with Jest and React-testing-library might be a bit tricky, in this article we’re going to demonstrate it and see how easily it can be done.

Following to our previous articles which i explained how to use React-Query useInfiniteQuery and covering it with unit testing with 100% coverage using Jest and React-testing-library and Nock/MSW.

In this article i will cover the React-Query useMutation hook and how to test it with Nock/MSW.

Prerequisites

This article will be dependent on a previous articles we published. It’s preferred to read those articles before continuing in this one.

  1. How to test Infinite Query With Jest And React-testing-library.
  2. How to test Infinite Query With Jest And React-testing-library.
  3. Testing React-Query with Mock Service Worker

Note: Full source code that have example for testing hooks using both libraries (Nock and MSW):

Let’s get started!

We gonna keep it simple, here’s the results of the expected UI, that contains the form inputs and a button to submit the form, and we’ve the list of the users below

To do that:

1- Add the following CSS snippet to the file index.css

.form-input {
  margin: 10px 0;
  padding: 5px 0;
}

.form-input label {
  color: black;
  font-weight: bold;
  margin-right: 10px;
} 

.form-input input {
  height: 25px;
  width: 20%;
  padding-left: 10px;
  font-size: 18px;
} 

.submit-button {
  width: 35%;
  font-size: 18px;
  font-weight: bold;
  background-color:#ccc;
  border: 0;
  border-radius: 4px;
  padding: 10px;
  cursor: pointer;
  margin-bottom: 20px;
}

2- let’s create a new file called CreateUser.tsx and put the following code in it:

import React, { FormEvent } from 'react';

export default function CreateUser() {

  const onSubmitHandler = async (form: FormEvent<HTMLFormElement>) => {
    form.preventDefault();
    const formTarget = (form.target as HTMLFormElement)
    const formData = new FormData(formTarget);
    const firstName = formData.get('firstName') as string;
    const lastName = formData.get('lastName') as string;
    const email = formData.get('email') as string;

    console.log(firstName, lastName, email);
  }

  return (
    <div style={{ margin: '20px' }} className='create-user-form'>
      <h1>Create User:</h1>
      <form onSubmit={onSubmitHandler}>
        <div className='form-input'>
          <label htmlFor='firstNameInput'>First Name: </label>
          <input type="text" name="firstName" required id="firstNameInput" />
        </div>

        <div className='form-input'>
          <label htmlFor='lastNameInput'>Last Name: </label>
          <input type="text" name="lastName" required id="lastNameInput" />
        </div>

        <div className='form-input'>
          <label htmlFor='emailInput'>Email: </label>
          <input type="email" name="email" required id="emailInput" />
        </div>
        <br />
        <button type='submit' className="submit-button">Submit</button>
      </form>
      <hr />
    </div>
  );
}

We’ve created a form UI, that contains the inputs for firstName, lastName, email all of the fields are marked as required.

A Submit button is also added with type='submit', and we’ve attached a handler to handle the submit action to the form <form onSubmit={onSubmitHandler}>

In the onSubmitHandler function, we’re parsing the formValues as FormData object, then we’re extracting the form values (firstName, lastName, email) out of it.

3- Add CreateUser.tsx to the index.tsx file:

import ReactDOM from 'react-dom/client';
import { QueryClientProvider } from "react-query";
import App from "./App";
import CreateUser from './CreateUser';
import './index.css'
import reactQueryClient from "./queryClient";

const rootElement = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);

rootElement.render(
  <QueryClientProvider client={reactQueryClient}>
    <CreateUser />
    <App />
  </QueryClientProvider>
);

4- Let’s add the logic for useMutation hook to submit the form data to the backend.

Create a new file useCreateUserMutation.ts, and add the following logic to it:

import { useMutation } from "react-query";
import axiosClient from "./axiosClient";

export const useCreateUserMutation = () => {
  const mutation = useMutation(
    (request: { firstName: string; lastName: string; email: string }) => {
      return axiosClient({
        url: `/data/v1/user/create`,
        method: "POST",
        data: request,
        headers: {
          "app-id": "62f43477f19452557ba1ce76",
        },
      });
    }
  );

  return mutation;
};

We’ve used useMutation hook, and defined the request body parameters

(request: { firstName: string; lastName: string; email: string }) so we can pass them later from the onSubmitHandler.

5- Import the useUserCreateMutation hook in the file CreatesUser.tsx, it should results in the following implementation:

import React, { FormEvent, useState } from 'react';
import { useCreateUserMutation } from './useCreateUserMutation';

export default function CreateUser() {
  const [error, setError] = useState('');
  const { mutate, isLoading } = useCreateUserMutation();

  const onSubmitHandler = async (form: FormEvent<HTMLFormElement>) => {
    form.preventDefault();
    const formTarget = (form.target as HTMLFormElement);
    const formData = new FormData(formTarget);
    const firstName = formData.get('firstName') as string;
    const lastName = formData.get('lastName') as string;
    const email = formData.get('email') as string;

    if (!firstName) {
      setError('Please enter a valid first name');
      document.getElementById('firstNameInput')?.focus();
      return;
    }

    if (!lastName) {
      setError('Please enter a valid last name');
      document.getElementById('lastNameInput')?.focus();
      return;
    }

    if (!email) {
      setError('Please enter a valid email');
      document.getElementById('email')?.focus();
      return;
    }

    mutate({
      firstName,
      lastName,
      email
    }, {
      onSuccess: () => {
        console.log('asdasd')
        alert('User added successfully');
      },
      onError: (response) => {
        alert('Failed to create user');
        console.log(response);
      }
    })
  }

  return (
    <div style={{ margin: '20px' }} className='create-user-form' data-testid="create-user-form">
      <h1>Create User:</h1>
      <form onSubmit={onSubmitHandler}>
        <div className='form-input'>
          <label htmlFor='firstNameInput'>First Name: </label>
          <input type="text" name="firstName" id="firstNameInput" />
        </div>

        <div className='form-input'>
          <label htmlFor='lastNameInput'>Last Name: </label>
          <input type="text" name="lastName" id="lastNameInput" />
        </div>

        <div className='form-input'>
          <label htmlFor='emailInput'>Email: </label>
          <input type="email" name="email" id="emailInput" />
        </div>
        {
          error && <div style={{ color: 'red', fontSize: '18px' }}>{error}</div>
        }
        <br />
        <button type='submit' className="submit-button">Submit</button>
      </form>
      <hr />
    </div>
  );
}

At line 5, we’ve instantiated the useCreateUserMutation hook, and destructed the

{ mutate, isLoading } properties out of it.

At line 15, we’ve called the function mutate and passed the form values as the first argument in shape of object, and the 2nd argument we’ve declared the onSuccess and onError handlers.

Also note we’ve added the attribute data-testid over the form wrapper element, we will rely on this attribute in testing the UI component later, to make sure it’s renders correctly.

Now let’s try this in the browser, open the app and fill the form data and submit it, you should receive User added successfully if everything went well.

Add user form

If you got the form submitted successfully, navigate in the users list, you should see the new item added there.


And in case of error, you will receive an alert with Failed to create user message, and details about the error would be printed in the browser console.

Testing

Now we’ve done from creating the form and connecting it to the backend using useMutation hook, let’s jump into adding tests for both the hook and the form.

1- useCreateUserMutation Testing:

Create a new file under the __tests__ folder, named as useCreateUserMutation.test.tsx

import { renderHook } from '@testing-library/react-hooks';
import { act } from '@testing-library/react';
import nock from 'nock';
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { useCreateUserMutation } from '../useCreateUserMutation';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: false,
    },
  },
})

const wrapper = ({ children }: { children: ReactNode }) => (
  <QueryClientProvider client={queryClient}>
    {children}
  </QueryClientProvider>
);

describe("useCreateUserMutation", () => {
  // 1- Adding test case for the happy path
  it("should create new user", async () => {
    const { result, waitFor } = renderHook(() => useCreateUserMutation(), {
      wrapper: wrapper,
    });
    nock('https://dummyapi.io', {
      reqheaders: {
        'app-id': () => true
      }
    })
      .post(`/data/v1/user/create`)
      // Mocking the response with status code = 200
      .reply(200, {});

    act(() => {
      result.current.mutate({
        firstName: 'fTest',
        lastName: 'lTest',
        email: 'eTest@test.com'
      });
    });

    // Waiting for the request status to resolve as success, i.e: statusCode = 200
    await waitFor(() => {
      return result.current.isSuccess;
    });

    // Make sure the request status resolved to true
    expect(result.current.isSuccess).toBe(true);
  });

  // 2- Adding test case for the sad path (i.e when no form data sent to the backend)
  it("should return an error from the server", async () => {
    const { result, waitFor } = renderHook(() => useCreateUserMutation(), {
      wrapper: wrapper,
    });
    nock('https://dummyapi.io', {
      reqheaders: {
        'app-id': () => true
      }
    })
      .post(`/data/v1/user/create`)
      // 2- Mocking the response with status code = 200
      .reply(400);

    act(() => {
      result.current.mutate({
        firstName: '',
        lastName: '',
        email: ''
      });
    });

    // Waiting for the request status to resolve as success, i.e: statusCode = 200
    await waitFor(() => {
      return result.current.isError;
    });

    // Make sure the request status resolved to true
    expect(result.current.isError).toBe(true);
  });
});

In this file, we’ve added two test cases, the 1st for the happy path of submitting a request to the backend with valid form data, And we’re expecting the request to resolve in success state, i.e: response status code is 200/201.

And for the 2nd case, we’ve added a test case for the sad path, meaning we’re submitting a request to the backend with wrong/incomplete form values, and the server respond with error status, i.e: status code is in the 400’s range.

You can add other test cases for sure to cover different permutations,

By running the test script for the above added file, you must see the following results:

 PASS  src/__tests__/useCreateUserMutation.test.tsx
  useCreateUserMutation
    ✓ should create new user (34 ms)
    ✓ should return an error from the server (12 ms)

--------------------------|---------|----------|---------|---------|-------------------
File                      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
--------------------------|---------|----------|---------|---------|-------------------
All files                 |    9.61 |        0 |   13.33 |    9.61 |                             
 useCreateUserMutation.ts |     100 |      100 |     100 |     100 |            
--------------------------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        1.127 s
Ran all test suites matching /useCreateUserMutation.test.tsx/i.
✨  Done in 2.23s.

2- CreateUser form Testing:

At this stage, we will test the UI view with mocking any server request same as we’ve done in the previous section.

Let’s create a new file under the __tests__ folder, named as CreateUser.test.tsx

In this file, we will create the queryClient and wrapper as we did before (you can extract them into a shared place and re-use them across different tests).

As well, since we’ve used window.alert to notify us about the status of the form submissions (success/failure), we need to mock window.alert function, let’s see in the example below:

import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import nock from 'nock';
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import CreateUser from '../CreateUser';

const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            retry: false,
        },
    },
})

const wrapper = ({ children }: { children: ReactNode }) => (
    <QueryClientProvider client={queryClient}>
        {children}
    </QueryClientProvider>
);

// Create Jest mocked function and assign it to global.alert
const mockedAlert = jest.fn();
global.alert = mockedAlert;

describe('CreateUser', () => {
  
});

So nothing new in the above other than creating the mock function for window.alert.


Now, let’s create our first test case for testing the component gets rendered correctly,

it('renders', () => {
    render(<CreateUser />, { wrapper });
    expect(screen.getByTestId('create-user-form')).toBeInTheDocument();
});

We basically rendered the component inside the wrapper, then we do assertion by querying the component by

data-testid and check if it exists inside the document.

The next test case would be for testing the fields are getting rendered correctly as well,

describe('CreateUser', () => {
    let firstName: HTMLInputElement;
    let lastName: HTMLInputElement;
    let email: HTMLInputElement;
    let submitButton: HTMLButtonElement;

    it('renders', () => {
        render(<CreateUser />, { wrapper });
        expect(screen.getByTestId('create-user-form')).toBeInTheDocument();
    });

    it('renders all of the fields', () => {
        render(<CreateUser />, { wrapper });
        firstName = screen.getByLabelText('First Name:');
        lastName = screen.getByText('Last Name:');
        email = screen.getByLabelText('Email:');

        expect(firstName).toBeInTheDocument();
        expect(lastName).toBeInTheDocument();
        expect(email).toBeInTheDocument();
    });
});

We’ve declared a variables to hold the DOM representation of the inputs/button elements inside the test suite, so that we can re-use them across different test cases.

Inside the 2nd test case we basically mount the component, then we query the input fields by their labels and checking if they are exists in the document.


Then next test case, would be for validating the form inputs before submitting the form, then filling the form with correct data and submit it.

Before diving into this test case, let’s make sure to update the @testing-library/user-event to version 14.4.0, since the userEvents.setup function has an exporting issue and that was resolved in version 14.4.0. You can checkout more about the issue from github.

We also need to make sure to intercept any server request by Nock same as we did before.

To do that let’s update the package

yarn upgrade @testing-library/user-event@14.4.0

Now let’s add the test case implementation:

    it('handles validation and submits the form successfully', async () => {
        const user = userEvent.setup();

        render(<CreateUser />, { wrapper });
        firstName = screen.getByLabelText('First Name:');
        lastName = screen.getByText('Last Name:');
        email = screen.getByLabelText('Email:');
        submitButton = screen.getByText('Submit');

        // Click the submit button without filling the form
        await user.click(submitButton);

        // First name input is empty
        expect(screen.getByText('Please enter a valid first name')).toBeInTheDocument();

        // Fill first name input by any value
        await user.type(firstName, 'JS');
        await user.click(submitButton);

        // Last name input is empty
        expect(screen.getByText('Please enter a valid last name')).toBeInTheDocument();

        // Fill last name input by any value
        await user.type(lastName, 'User');
        await user.click(submitButton);

        // Email input is empty
        expect(screen.getByText('Please enter a valid email')).toBeInTheDocument();

        // Fill email input by any value
        await user.type(email, 'User@js-howto.com');

        // Mocking the backend request
        nock('https://dummyapi.io', {
            reqheaders: {
                'app-id': () => true
            }
        })
            .post(`/data/v1/user/create`)
            // Mocking the response with status code = 200
            .reply(200, {});

        await user.click(submitButton);
        await waitFor(() => {
            expect(mockedAlert).toHaveBeenCalledWith('User added successfully')
        });
    });

So, we’ve mounted the component, then passed over the fields one by one, submit the form without filling any data would result in the message: Please enter a valid first name

Then after filling the first name value, we submit the form again, and expecting to get an error message of

Please enter a valid last name, same happen when we fill the last name and try to submit the form without filling the email address, we will get the following message: Please enter a valid email.

Then, after filling the email address with a correct value, we will have the form values are valid and we can submit the form, thus we’ve added Nock post request interception, and after submitting the form, we are validating that by having a test the checks if the mocked window.alert is being called with the value User added successfully.


Now, let’s add the last test case which would be for submitting the form and expecting to receive an error from the backend, then we do confirm that by validating window.alert is being called with the message:

Failed to create user

    it('submits the form with errors', async () => {
        const user = userEvent.setup();

        render(<CreateUser />, { wrapper });
        firstName = screen.getByLabelText('First Name:');
        lastName = screen.getByText('Last Name:');
        email = screen.getByLabelText('Email:');
        submitButton = screen.getByText('Submit');

        // Fill form values
        await user.type(firstName, 'JS');
        await user.type(lastName, 'User');
        await user.type(email, 'User@js-howto.com');

        // Mocking the backend request
        nock('https://dummyapi.io', {
            reqheaders: {
                'app-id': () => true
            }
        })
            .post(`/data/v1/user/create`)
            // Mocking the response with status code = 500
            .reply(500, {});

        await user.click(submitButton);
        await waitFor(() => {
            expect(mockedAlert).toHaveBeenCalledWith('Failed to create user')
        });
    });

Note in this case we’ve mocked request status 500 as internal server error.

Putting it all together:

import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import nock from 'nock';
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import CreateUser from '../CreateUser';

const queryClient = new QueryClient({
    defaultOptions: {
        queries: {
            retry: false,
        },
    },
})

const wrapper = ({ children }: { children: ReactNode }) => (
    <QueryClientProvider client={queryClient}>
        {children}
    </QueryClientProvider>
);

const mockedAlert = jest.fn()
global.alert = mockedAlert;

describe('CreateUser', () => {
    let firstName: HTMLInputElement;
    let lastName: HTMLInputElement;
    let email: HTMLInputElement;
    let submitButton: HTMLButtonElement;

    it('renders', () => {
        render(<CreateUser />, { wrapper });
        expect(screen.getByTestId('create-user-form')).toBeInTheDocument();
    });

    it('renders all of the fields', () => {
        render(<CreateUser />, { wrapper });
        firstName = screen.getByLabelText('First Name:');
        lastName = screen.getByText('Last Name:');
        email = screen.getByLabelText('Email:');

        expect(firstName).toBeInTheDocument();
        expect(lastName).toBeInTheDocument();
        expect(email).toBeInTheDocument();
    });

    it('handles validation and submits the form successfully', async () => {
        const user = userEvent.setup();

        render(<CreateUser />, { wrapper });
        firstName = screen.getByLabelText('First Name:');
        lastName = screen.getByText('Last Name:');
        email = screen.getByLabelText('Email:');
        submitButton = screen.getByText('Submit');

        // Click the submit button wihtout filling the form
        await user.click(submitButton);

        // First name input is empty
        expect(screen.getByText('Please enter a valid first name')).toBeInTheDocument();

        // Fill first name input by any value
        await user.type(firstName, 'JS');
        await user.click(submitButton);

        // Last name input is empty
        expect(screen.getByText('Please enter a valid last name')).toBeInTheDocument();

        // Fill last name input by any value
        await user.type(lastName, 'User');
        await user.click(submitButton);

        // Email input is empty
        expect(screen.getByText('Please enter a valid email')).toBeInTheDocument();

        // Fill email input by any value
        await user.type(email, 'User@js-howto.com');

        // Mocking the backend request
        nock('https://dummyapi.io', {
            reqheaders: {
                'app-id': () => true
            }
        })
            .post(`/data/v1/user/create`)
            // Mocking the response with status code = 200
            .reply(200, {});

        await user.click(submitButton);
        await waitFor(() => {
            expect(mockedAlert).toHaveBeenCalledWith('User added successfully')
        });
    });

    it('submits the form with errors', async () => {
        const user = userEvent.setup();

        render(<CreateUser />, { wrapper });
        firstName = screen.getByLabelText('First Name:');
        lastName = screen.getByText('Last Name:');
        email = screen.getByLabelText('Email:');
        submitButton = screen.getByText('Submit');

        // Fill form values
        await user.type(firstName, 'JS');
        await user.type(lastName, 'User');
        await user.type(email, 'User@js-howto.com');

        // Mocking the backend request
        nock('https://dummyapi.io', {
            reqheaders: {
                'app-id': () => true
            }
        })
            .post(`/data/v1/user/create`)
            // Mocking the response with status code = 500
            .reply(500, {});

        await user.click(submitButton);
        await waitFor(() => {
            expect(mockedAlert).toHaveBeenCalledWith('Failed to create user')
        });
    });
});

Executing the above test file would result in the following:

yarn test -- src/__tests__/CreateUser.test.tsx

 PASS  src/__tests__/CreateUser.test.tsx
  CreateUser
    ✓ renders (18 ms)
    ✓ renders all of the fields (7 ms)
    ✓ handles validation and submits the form successfully (194 ms)
    ✓ submits the form with errors (133 ms)

--------------------------|---------|----------|---------|---------|-------------------
File                      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
--------------------------|---------|----------|---------|---------|-------------------
All files                 |   47.69 |    38.09 |      40 |   47.69 |        
 CreateUser.tsx           |     100 |      100 |     100 |     100 |           
--------------------------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total
Snapshots:   0 total
Time:        1.284 s
Ran all test suites matching /src\/__tests__\/CreateUser.test.tsx/i.
✨  Done in 2.49s.

That’s it for React-Query useMutation with Jest Testing

And as always happy coding!

Full code example on github.

Photo from unsplash

Related Posts

How to Capture Screenshots with Puppeteer In NodeJS

How to Capture Screenshots with Puppeteer In NodeJS

To Capture Screenshots with Puppeteer: Launch a Browser Instance Navigate to the Web Page Capture the Screenshot Introduction: Puppeteer is a powerful Node.js library that allows developers…

How to Minimize Puppeteer Browser Window To Tray

How to Minimize Puppeteer Browser Window To Tray

Puppeteer is a powerful tool for automating tasks in headless or non-headless web browsers using JavaScript. While Puppeteer is often used to perform actions within a browser,…

Intercepting Responses in Node.js with Puppeteer

Intercepting Responses in Node.js with Puppeteer

Introduction: Puppeteer is a powerful Node.js library that provides a high-level API for controlling headless Chrome or Chromium browsers. It’s widely used for web scraping, automated testing,…

Mastering React Component Re-rendering in Jest

Mastering React Component Re-rendering in Jest

In this hands-on guide, we’ll explore the art of optimizing React component re-rendering within Jest tests. By combining theory with practical coding examples, you’ll gain a deep…

Eliminating Nesting Loops in React Rendering

Eliminating Nesting Loops in React Rendering

React has ushered in a new era of web application development with its component-based structure, promoting code reusability and maintainability. But as projects evolve, achieving optimal performance…

Exploring Type and Interface Usage in TypeScript

Exploring Type and Interface Usage in TypeScript

TypeScript has gained immense popularity by bridging the gap between dynamic JavaScript and static typing. Two of its fundamental features, “Type” and “Interface,” play pivotal roles in…

Leave a Reply

%d bloggers like this: