How to test Infinite Query With Jest And React-testing-library

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 not straight-forward, in this article we’re going to demonstrate it and see how easily it can be.

Prerequisites

In this article we will be covering React-Query useInfiniteQuery hook with lazy loading data using InfiniteScroll library, along with the following testing tools:

Use Case

As we aren’t building a full-stack application in this article, we will use the DummyApi as our backend (you can navigate to their site and generate unique app-id), And we will create a simple list view that displays the user first name and last name with the ability to scroll down and fetch more data as the user scrolls down, until no more users data to fetch.

We will be covering testing both UI components and Custom hooks functionality.

Users list view using React-query InfiniteQuery hook.
Users list view using React-query InfiniteQuery hook.

Note: Full source code can be found on the following link:

Let’s get started!

Initial Setup

We will user Create-React-App to create the app, open your terminal and execute the following command:

npx create-react-app testing-react-query-components --template typescript

Now move to the project directory:

cd testing-react-query-components

We need to install the rest of the libraries, so execute the following command inside the project directory, which will install react-query along with react-infinite-scroll-component:

yarn add react-infinite-scroll-component react-query

Let’s as well add the testing dev dependencies, execute the following command to install them:

yarn add @testing-library/jest-dom @testing-library/react @testing-library/user-event @types/jest

Now all of the dependencies should be installed, let’s start coding!

Inside index.tsx file, let’s add the following code:

import { render } from "react-dom";
import { QueryClient, QueryClientProvider } from "react-query";
import App from "./App";
import './index.css'

const queryClient = new QueryClient();

const rootElement = document.getElementById("root");

render(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>,
  rootElement
);

At the above file, we’re simply initializing a new QueryClient client and wrapping the App component with it, we will get later to the App component, now let’s build the logic!

Create a new types.d.ts file which will contain the typescript declarations

export type User = {
  firstName: string;
  lastName: string;
};

export type UsersPage = {
  results: User[];
  next: number | undefined;
};

The User type, simply represents the user first and last name and the UsersPage type will represent the response that we’re getting from the server, in which we will have an array/list of User and number next to indicate if there are any more users data to fetch.

useInfiniteQuery custom hook implementation

import { useInfiniteQuery } from 'react-query';
import { UsersPage } from './types';

async function getData({ pageParam = 0 }) {
    const response = await fetch(`https://dummyapi.io/data/v1/user?page=${pageParam}&limit=50`, {
        headers: {
            'app-id': '62f43477f19452557ba1ce76',
        },
    });
    if (!response.ok) {
        throw new Error('Problem fetching data');
    }
    const dataFromServer = await response.json();

    const data: UsersPage = {
        results: dataFromServer.data,
        next:
            dataFromServer.total > dataFromServer.page * dataFromServer.limit
                ? pageParam + 1
                : undefined,
    };
    return data;
}

export const useUsersQuery = () => {
    const query = useInfiniteQuery<UsersPage, Error>('users', getData, {
        getNextPageParam: (lastPage) => lastPage.next,
    });

    return query;
};

getData is responsible on fetching the users data from dummyapi.io using the browser fetch api, and it does check if the response is not valid/ok (line:10) we throw an error with the message Problem fetching data.

If the response is valid, we convert the response to json, using the method response.json(), then we create an object of type UsersPage that will include an array of users results (line:16), and a number stored in the next property (line: 17), that indicates if more results needs to be fetched.

The logic on identifying if there are more results to fetch from the backend or not is done by checking the total results we receive from response is greater than page size multiplied by the limit, if it’s greater then we increment the page number by 1, if not we simply return undefined which React-query will use internally to stop fetching/paginating the request.

Here’s an example of that:

On the first request, we issue the following request

https://dummyapi.io/data/v1/user?page=0&limit=50

note that the page number is 0 and the limit is 50, is response to that we receive the following respoonse:

{
    "data": [
        {
            "id": "60d0fe4f5311236168a109ca",
            "title": "ms",
            "firstName": "Sara",
            "lastName": "Andersen",
            "picture": "https://randomuser.me/api/portraits/women/58.jpg"
        },
        ...up to 49 another object
    ],
    "total": 99,
    "page": 0,
    "limit": 50
}

note that the total number of results is 99 and the page number is 0 and the limit is 50, running our calculations

total > page * limit

99 > 0 * 50, which will results in true, so we increment the page number by 1.

In the next request the api url will be

https://dummyapi.io/data/v1/user?page=1&limit=50

Note that the page number is changed from 0 to 1, and so on…

useUsersQuery custom hook, is simply a calling React-query useInfiniteQuery, first we’re setting the query key to users, and we’re passing the getData function as a 2nd parameter, in the last parameter, we’re passing an object that contains function definition for the getNextPageParam property, we simply returning the next page number, or undefined!

So this so far the logic needed to fetch the user data from the backend and paginate through the pages, until no more data to fetch.

Now moving to the UI part, create/edit the App.tsx file as the following:

import React from 'react';
import { useUsersQuery } from './useInfiniteQuery';
import InfiniteScroll from 'react-infinite-scroll-component';
import { User } from './types';

export default function App() {
  const { data, error, fetchNextPage, hasNextPage, status } = useUsersQuery();

  if (status === 'loading') {
    return <div>Loading users...</div>;
  }
  if (status === 'error') {
    return <div>{error?.message}</div>;
  }
  if (data === undefined) {
    return <div>No users data found!</div>;
  }
  const dataLength = data.pages.reduce((counter, page) => {
    return counter + page.results.length;
  }, 0);

  return (
    <div style={{ margin: '20px' }}>
      <h1>Users List</h1>
      <div style={{ border: '1px solid #ccc', height: '400px', overflow: 'scroll' }} id="scrollbar-target">
        <InfiniteScroll
          dataLength={dataLength}
          next={fetchNextPage}
          hasMore={!!hasNextPage}
          loader={<p style={{ color: 'deepskyblue', fontSize: '20px', textAlign: 'center' }}><b>Loading...</b></p>}
          data-testid="infinite-scroll"
          scrollableTarget="scrollbar-target"
          style={{ padding: '5px' }}
          endMessage={
            <p style={{ textAlign: 'center' }}>
              <b>Yay! You have seen it all</b>
            </p>
          }
        >
          {data.pages.map((group, i) => (
            <React.Fragment key={i}>
              {group.results.map((user: User) => (
                <p key={user.firstName + user.lastName}>
                  {user.firstName} {user.lastName}
                </p>
              ))}
            </React.Fragment>
          ))}
        </InfiniteScroll>
      </div>
    </div>
  );
}

Here’s the description of the properties we’re destructuring from useUsersQuery hooks:

  • data: It will include the users pages array, and in each page we will have an array of users (remember in the file types.d.ts we declared UsersPage type).
  • error: In case of any error happened in the custom hook/api error, it will be stored in this variable and we show that in the screen in case.
  • status: This can hold many states, for now we’re only checking the loading/error statuses.
  • fetchNextPage: This will be called in case we have more results to fetch from the backend.
  • hasNextPage: A boolean value indicates if we have another page to fetch or not.

At Line:18, note we are calculating the size of the data that we have so far, this will help InfiniteScroll component to decide in calling the next function or not.

That’s all for making our app works, now running the app by executing the following command from the terminal

yarn start

A new page should open in the browser and you should see the following list of users, As you scroll down in the list, you will notice the Loading… message, while fetching the next page of data from the server, till you reach to the end of the results, as shown below!

Lazy loading users using useInfiniteQuery and InfiniteScroll

Components testing

Looking at the App.tsx component, we can see that it does a few things:

  • Calls useUsersQuery hook.
  • Renders a loading message when status === 'loading' .
  • Renders an error message when status === 'error' .
  • Renders No users data found when data === undefined
  • Renders the component data when it’s available.

Therefore, we need to test all of these cases.

Our tests will rely on jest.mock which in turn will allow us to mock the implementation of the useUsersQuery. This avoids wrapping the component with ReactQueryProvider as we will be testing the component behavior and not the custom react-query hook itself.

Let’s start testing case by case.

Create a new folder under the src directory for tests, name it __tests__, and inside the new __tests__ folder create a new file called App.test.tsx, also make sure to import @testing-library/jest-dom inside the src/setupTests.ts file

import "@testing-library/jest-dom";

1- Testing the app renders correctly without crashing

import React from "react";
import { render, screen } from "@testing-library/react";
import { useUsersQuery } from "../useInfiniteQuery";
import App from "../App";

// Make TypeScript Happy, by resolving TS errors
const mockedUseUsersQuery = useUsersQuery as jest.Mock<any>;

// Mock the hook module
jest.mock("../useInfiniteQuery");

describe("<App />", () => {
  beforeEach(() => {
    mockedUseUsersQuery.mockImplementation(() => ({ isLoading: true }));
  });
  afterEach(() => {
    jest.clearAllMocks();
  });

  it("Renders without crashing", () => {
    render(<App />);
  });
});

run the command yarn test from the terminal, and you should see the test is passing

Testing app renders without crashing

2- Adding test for the loading state

import React from "react";
import { render, screen } from "@testing-library/react";
import { useUsersQuery } from "../useInfiniteQuery";
import App from "../App";

// Make TypeScript Happy, by resolving TS errors
const mockedUseUsersQuery = useUsersQuery as jest.Mock<any>;

// Mock the hook module
jest.mock("../useInfiniteQuery");

describe("<App />", () => {
  beforeEach(() => {
    mockedUseUsersQuery.mockImplementation(() => ({ isLoading: true }));
  });
  afterEach(() => {
    jest.clearAllMocks();
  });

  it("Renders without crashing", () => {
    render(<App />);
  });

  it("Displays loading message", () => {
    mockedUseUsersQuery.mockImplementation(() => ({
      status: 'loading',
    }));
    render(<App />);
    expect(screen.getByText(/Loading users.../i)).toBeInTheDocument()
  });
});
Testing loading state

3- Testing error state

import React from "react";
import { render, screen } from "@testing-library/react";
import { useUsersQuery } from "../useInfiniteQuery";
import App from "../App";

// Make TypeScript Happy, by resolving TS errors
const mockedUseUsersQuery = useUsersQuery as jest.Mock<any>;

// Mock the hook module
jest.mock("../useInfiniteQuery");

describe("<App />", () => {
  beforeEach(() => {
    mockedUseUsersQuery.mockImplementation(() => ({ isLoading: true }));
  });
  afterEach(() => {
    jest.clearAllMocks();
  });

  it("Renders without crashing", () => {
    render(<App />);
  });

  it("Displays loading message", () => {
    mockedUseUsersQuery.mockImplementation(() => ({
      status: 'loading',
    }));
    render(<App />);
    expect(screen.getByText(/Loading users.../i)).toBeInTheDocument()
  });

  it("Displays error message", () => {
    mockedUseUsersQuery.mockImplementation(() => ({
      status: 'error',
      error: {
        message: 'An error occured!'
      }
    }));
    render(<App />);
    expect(screen.getByText(/An error occured!/i)).toBeInTheDocument()
  });
});
Testing error state

4- Testing no data found case

import React from "react";
import { render, screen } from "@testing-library/react";
import { useUsersQuery } from "../useInfiniteQuery";
import App from "../App";

// Make TypeScript Happy, by resolving TS errors
const mockedUseUsersQuery = useUsersQuery as jest.Mock<any>;

// Mock the hook module
jest.mock("../useInfiniteQuery");

describe("<App />", () => {
  beforeEach(() => {
    mockedUseUsersQuery.mockImplementation(() => ({ isLoading: true }));
  });
  afterEach(() => {
    jest.clearAllMocks();
  });

  it("Renders without crashing", () => {
    render(<App />);
  });

  it("Displays loading message", () => {
    mockedUseUsersQuery.mockImplementation(() => ({
      status: 'loading',
    }));
    render(<App />);
    expect(screen.getByText(/Loading users.../i)).toBeInTheDocument()
  });

  it("Displays error message", () => {
    mockedUseUsersQuery.mockImplementation(() => ({
      status: 'error',
      error: {
        message: 'An error occured!'
      }
    }));
    render(<App />);
    expect(screen.getByText(/An error occured!/i)).toBeInTheDocument()
  });

  it("Displays no data found message", () => {
    mockedUseUsersQuery.mockImplementation(() => ({
      status: 'loaded',
      data: undefined
    }));
    render(<App />);
    expect(screen.getByText(/No users data found!/i)).toBeInTheDocument()
  });
});
Testing no data found case.

5- Testing rendering the component when we receive the data from the backend, for this case let’s create a new fixtures file under the src directory, that will contain the mocked response per page, full source code can be found on the following link

Now, let’s get back to the test file and add the 5th case

import React from "react";
import { render, screen } from "@testing-library/react";
import { useUsersQuery } from "../useInfiniteQuery";
import App from "../App";
import { responseForPage0 } from "../fixtures";

// Make TypeScript Happy, by resolving TS errors
const mockedUseUsersQuery = useUsersQuery as jest.Mock<any>;

// Mock the hook module
jest.mock("../useInfiniteQuery");

describe("<App />", () => {
  beforeEach(() => {
    mockedUseUsersQuery.mockImplementation(() => ({ isLoading: true }));
  });
  afterEach(() => {
    jest.clearAllMocks();
  });

  it("Renders without crashing", () => {
    render(<App />);
  });

  it("Displays loading message", () => {
    mockedUseUsersQuery.mockImplementation(() => ({
      status: 'loading',
    }));
    render(<App />);
    expect(screen.getByText(/Loading users.../i)).toBeInTheDocument()
  });

  it("Displays error message", () => {
    mockedUseUsersQuery.mockImplementation(() => ({
      status: 'error',
      error: {
        message: 'An error occured!'
      }
    }));
    render(<App />);
    expect(screen.getByText(/An error occured!/i)).toBeInTheDocument()
  });

  it("Displays no data found message", () => {
    mockedUseUsersQuery.mockImplementation(() => ({
      status: 'loaded',
      data: undefined
    }));
    render(<App />);
    expect(screen.getByText(/No users data found!/i)).toBeInTheDocument()
  });

  it("Displays the users list", () => {
    mockedUseUsersQuery.mockImplementation(() => ({
      status: 'success',
      data: {
        pages: [{ results: responseForPage0.data, next: 1 }]
      }
    }));
    render(<App />);
    expect(screen.getByText(`${responseForPage0.data[0].firstName} ${responseForPage0.data[0].lastName}`)).toBeInTheDocument()
  });
});

And by running that our test file should 100% covered and should looks green!

100% test coverage for useInfiniteQuery hook with infiniteScroll

That’s it for using useInfiniteQuery hook and InfiniteScroll component, with implementing 100% test coverage for all of the use cases.

Please note that for testing the custom hook functionalities you can mock the browser Fetch API, and that would work, but it won’t support pagination, which means you won’t be able to cover the functionality 100%, a following up article will be made to configure the app with Axios HTTP client, and implement 100% test coverage for the useUserQuery custom hook.

And as always, happy coding! πŸ₯³πŸ‘‹πŸ»

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: