Blog

Testing React Native with React Query and Zustand

April 30, 2022 12 min read

A comprehensive guide to testing React Native applications using React Query for data fetching and Zustand for state management. Learn best practices and testing strategies.

Testing React Native with React Query and Zustand
React NativeTestingReact QueryZustandState Management

React Native has become a popular choice for building mobile applications due to its fast development cycle and cross-platform compatibility. To make the most out of React Native development, developers often turn to third-party libraries like React-Query and Zustand to handle data fetching and state management. React-Query provides a flexible and efficient way to fetch, cache, and manage your data, while Zustand makes state management simple and powerful with its minimalist approach.

In this article, we will explore how to effectively test React Native applications that use these two libraries. We will be using a simple store app as an example and will provide comprehensive and detailed examples to help you understand the testing process. By the end of this article, you will have a good understanding of how to use and test React-Query and Zustand in your React Native apps

1 — Setting up the environment

We will be using React Native version 0.71.2 to initialize our store app and React-Query(Tanstack query) version 3.39.3 and Zustand version 4.3.2 to handle data fetching and state management. I will provide the full source code for the complete app on GitHub, so if you want to jump in and see it in action, you can do so by accessing the link which is shared at the end of the article. This will allow you to follow along with the examples and see the complete code for the app.

1.1 — Setting up store API

We will be using https://fakestoreapi.com as our API to demonstrate effective testing techniques in React Native. Our store app will fetch a set of products from this API and provide a detailed view of each product. Although the FakeStoreProduct has other REST APIs that we could utilize, doing so would increase complexity and detract from the primary objective of this article.

To communicate with our API across our app, we will create an axios instance.

1.2— Setup axios instance

import axios from "axios";

export const BASE_URL = "https://fakestoreapi.com";

export const api = axios.create({
  baseURL: BASE_URL,
});

Next steps will be creating our react-query setup for our API.

1.3 — Create the key-factory for managing query caching

What is key-factory?

At its core, TanStack Query manages query caching for you based on query keys. Query keys have to be an Array at the top level, and can be as simple as an Array with a single string, or as complex as an array of many strings and nested objects. As long as the query key is serializable, and unique to the query’s data, you can use it!

Although the simplicity of our app does not necessarily require the use of caching, we will be utilizing React-Query’s powerful caching features nonetheless. Thus, we will be creating a simple key factory, which will be demonstrated below.

export const productKeyFactory = {
  products: ["all-products"],
  productById: (id: number) => [...productKeyFactory.products, id],
} as const;

1.4— Fetching products using react-query

import { AxiosError } from "axios";
import { useQuery, UseQueryOptions } from "react-query";
import { api } from "../axios.instance";
import { productKeyFactory } from "./key-factory";
import { Product } from "./types";

export const getAllProducts = async () => {
  return (await api.get<Array<Product>>("/products")).data;
};

export const useGetAllProducts = (
  options?: UseQueryOptions<
    Array<Product>,
    AxiosError,
    Array<Product>,
    readonly [string]
  >
) => {
  return useQuery({
    queryKey: [...productKeyFactory.products],
    queryFn: getAllProducts,
    ...options,
  });
};

Above, we’ve created useGetAllProducts hook for fetching the products, as well as utilizing the query-key we created in the previous section.

1.5 — Fetching product detail using react-query

import { AxiosError } from "axios";
import { useQuery, UseQueryOptions } from "react-query";
import { api } from "../axios.instance";
import { productKeyFactory } from "./key-factory";
import { Product } from "./types";

export const getProductById = async (productId: number) => {
  return (await api.get<Product>(`/products/${productId}`)).data;
};

export const useGetProductById = (
  productId: number,
  options?: UseQueryOptions<
    Product,
    AxiosError,
    Product,
    readonly (string | number)[]
  >
) => {
  return useQuery({
    queryFn: () => getProductById(productId),
    queryKey: [...productKeyFactory.productById(productId)],
    ...options,
  });
};

Almost the same as the previous hook, except this time useGetProductById hook will be used for fetching the product’s detail by its id.

1.6— Product type

Let’s not skip to get an idea of how the product object does look.

export interface Product {
  id: number;
  title: string;
  price: number;
  description: string;
  category: string;
  image: string;
  rating: Rating;
}

export interface Rating {
  rate: number;
  count: number;
}

1.7 — Here comes zustand, managing products in the basket

The app will include basic features that allow users to add or remove products from their basket, as well as adjust the quantity of individual products. Additionally, users have the ability to reset the entire basket. These functionalities will be managed using Zustand. Below, we will examine its implementation.

import { create } from "zustand";
import { shallow } from "zustand/shallow";
import { Product } from "../api/product";

export type ProductInBasket = {
  product: Product;
  quantity: number;
};

export const updateProductQuantity = (
  productsInBasket: Array<ProductInBasket>,
  productId: number,
  updateType: "increase" | "decrease"
) => {
  return productsInBasket.map((productInBasket) => {
    if (productInBasket.product.id === productId) {
      return {
        ...productInBasket,
        quantity:
          updateType === "increase"
            ? productInBasket.quantity + 1
            : productInBasket.quantity - 1,
      };
    }
    return productInBasket;
  });
};

export const increaseProductQuantityInBasket = (
  productsInBasket: Array<ProductInBasket>,
  productId: number
) => {
  return updateProductQuantity(productsInBasket, productId, "increase");
};

export const decreaseProductQuantityInBasket = (
  productsInBasket: Array<ProductInBasket>,
  productId: number
) => {
  return updateProductQuantity(productsInBasket, productId, "decrease");
};

export interface ProductStore {
  productsInBasket: Array<ProductInBasket>;
  actions: {
    addProductToBasket: (val: Product) => void;
    removeProductFromBasket: (productId: number) => void;
    increaseProductQuantityInBasket: (productId: number) => void;
    decreaseProductQuantityInBasket: (productId: number) => void;
    resetAllProductsInBasket: () => void;
  };
}

export const useProductStore = create<ProductStore>((set, get) => ({
  productsInBasket: [],
  actions: {
    addProductToBasket: (product) =>
      set({
        productsInBasket: [
          ...get().productsInBasket,
          { product: product, quantity: 1 },
        ],
      }),
    removeProductFromBasket: (productId) =>
      set({
        productsInBasket: [
          ...get().productsInBasket.filter(
            (productInBasket) => productInBasket.product.id !== productId
          ),
        ],
      }),
    increaseProductQuantityInBasket: (productId) => {
      set({
        productsInBasket: increaseProductQuantityInBasket(
          get().productsInBasket,
          productId
        ),
      });
    },
    resetAllProductsInBasket: () => set({ productsInBasket: [] }),
    decreaseProductQuantityInBasket: (productId) => {
      set({
        productsInBasket: decreaseProductQuantityInBasket(
          get().productsInBasket,
          productId
        ),
      });
    },
  },
}));

export const useProductActions = () =>
  useProductStore((state) => state.actions);

export const useProductsInBasket = () =>
  useProductStore((state) => state.productsInBasket, shallow);
export const useProductsInBasketCount = () =>
  useProductStore((state) => state.productsInBasket.length);
export const useProductInBasketQuantityById = (productId: number | undefined) =>
  useProductStore(
    (state) =>
      state.productsInBasket.find(
        (productInBasket) => productInBasket.product.id === productId
      )?.quantity
  );

The first part of the code defines three functions, updateProductQuantity, increaseProductQuantityInBasket, and decreaseProductQuantityInBasket, which are helper methods for updating the quantity of a product in the basket. updateProductQuantity function takes an array of objects which corresponds to productsInBasket, a product ID, and an update type (increase or decrease) as inputs, and return a new array productsInBasket with the updated quantity for the given product.

The ProductStore interface defines the schema for the product store. It contains two properties:

  1. productsInBasket is an array of ProductInBasketobjects representing the products in the basket.

  2. actions is an object containing functions for managing the products in the basket, including adding a product, removing a product, increasing/decreasing its quantity, and resetting the basket.

The useProductStore hook is created using the create function from Zustand. It returns the product store with its state and actions.

The remaining hooks, useProductActions, useProductsInBasket, useProductsInBasketCount, and useProductInBasketQuantityById, are selectors derived from the useProductStore hook. They allow access to specific parts of the product store, such as the actions or the count of favorited products.

On the other hand, worth to mention that we used shallowfrom zustand in useProductsInBasket. It's because the array of objects that will be returned from useProductsInBasket will cause a rerender as they are not being primitive types*, thus we simply tell zustand that we want the object to be diffed shallowly by passing the shallow equality function.

1.8 — Installing necessary packages for testing

Now, we can move on to the part that we more care about, testing. We will be using the packages below:

For the purpose of brevity and to avoid elongating the article, we will omit some processes of configuring the testing environment such as jest.config.js and ts-jest .

Feel free to take a look at how we did end up setting up the test environment by using the complete source code link that is available at the end of the article.

1.9— Mocking API data with MSW

In order to effectively test our app with react-query, we need to mock our API. To accomplish this, we will utilize the mock service worker (MSW).

import { rest } from "msw";
import { setupServer } from "msw/node";
import { BASE_URL } from "../../src/api/axios.instance";
import {
  GET_ALL_PRODUCTS_MOCK_RESPONSE,
  GET_PRODUCT_BY_ID_MOCK_RESPONSE,
} from "./mock-data";

const getAllProductsUrl = BASE_URL + "/products";
const getProductByIdUrl = BASE_URL + `/products/:id`;

const getAllProductsHandler = rest.get(getAllProductsUrl, (_req, res, ctx) => {
  return res(ctx.status(200), ctx.json(GET_ALL_PRODUCTS_MOCK_RESPONSE));
});

const getProductByIdHandler = rest.get(getProductByIdUrl, (_req, res, ctx) => {
  return res(ctx.status(200), ctx.json(GET_PRODUCT_BY_ID_MOCK_RESPONSE));
});

const handlers = [getAllProductsHandler, getProductByIdHandler];

export const mswServer = setupServer(...handlers);

const getAllProductsFailedHandler = rest.get(
  getAllProductsUrl,
  (_req, res, ctx) => {
    return res(ctx.status(500));
  }
);

const getProductByIdFailedHandler = rest.get(
  getProductByIdUrl,
  (_req, res, ctx) => {
    return res(ctx.status(500));
  }
);

export const setupGetAllProductsFailedHandler = () =>
  mswServer.use(getAllProductsFailedHandler);

export const setupGetProductByIdFailedHandler = () =>
  mswServer.use(getProductByIdFailedHandler);

In the code above, we import the necessary modules and set up the MSW handlers. We create two mock endpoints, one for fetching all products and the other for fetching a product by its id, both returning a successful response. To ensure thorough testing, we also create failed handlers for these two endpoints to simulate and test failure scenarios.

In order to complete the mocking setup above, we need to use mswServer object in our jest.setup.js . We simply will want to initialize mswServer before any test cases, as well as reset handlers after each test case, and in the end, we will shut down it.

import "react-native-gesture-handler/jestSetup";
import { mswServer } from "./__mocks__/msw/handlers";

jest.useFakeTimers();

// https://mswjs.io/docs/getting-started/integrate/node#setup

// Establish API mocking before all tests.
beforeAll(() => mswServer.listen());
// Reset any request handlers that we may add during the tests,
// so they don't affect other tests.
afterEach(() => mswServer.resetHandlers());
// Clean up after the tests are finished.
afterAll(() => mswServer.close());

// Silence the warning: Animated: `useNativeDriver` is not supported because the native animated module is missing
jest.mock("react-native/Libraries/Animated/NativeAnimatedHelper");

Now, we mocked our API to be able to use it in the tests. But we still are not complete yet. We do also need to set up testing for zustand using the documentation they have provided. This will allow us to reset state between tests when using zustand in our test cases.

1.10 — Configuring zustand for testing

// https://docs.pmnd.rs/zustand/guides/testing#resetting-state-between-tests-in-react-dom
const { create: actualCreate } = jest.requireActual("zustand"); // if using jest
import { act } from "@testing-library/react-native";

// a variable to hold reset functions for all stores declared in the app
const storeResetFns = new Set();

// when creating a store, we get its initial state, create a reset function and add it in the set
export const create = (createState) => {
  const store = actualCreate(createState);
  const initialState = store.getState();
  storeResetFns.add(() => store.setState(initialState, true));
  return store;
};

// Reset all stores after each test run
beforeEach(() => {
  act(() => storeResetFns.forEach((resetFn) => resetFn()));
});

The crucial thing is here to put the file above in the correct directory depending on our testing environment. Since we use jest, we should place this file to the mocks/zustand.js as described in the documentation.

You might be already feeling overwhelmed dealing with setting up the (testing) environment, nevertheless, I needed to cover them regardless, as they are the skeleton of our environment.

That said, let’s jump right into the section in which we create our screen for displaying the products.

2 — Product list screen overview

As we mentioned before, we will have a screen for displaying products. Let’s see the full code of product list screen below:

import { DefaultTheme } from "@react-navigation/native";
import * as React from "react";
import {
  FlatList,
  ListRenderItemInfo,
  RefreshControl,
  StyleSheet,
  Text,
  View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Product, useGetAllProducts } from "../api/product";
import ProductListCard from "../components/product-list-card";
import ScreenLoading from "../components/screen-loading";
import Spacing from "../components/spacing";
import useRefreshByUser from "../hooks/useRefreshByUser";
import { RouteNames } from "../navigation/route-names";
import { ProductListScreenProps } from "../navigation/types";
import { useProductActions, useProductsInBasket } from "../store/product";
import { COMMON_STYLES } from "../theme/common-styles";

type Props = ProductListScreenProps;

const ProductListScreen: React.FC<Props> = ({ navigation }) => {
  const { data, isLoading, refetch, isError, isSuccess } = useGetAllProducts();

  const { isRefetchingByUser, refetchByUser } = useRefreshByUser(refetch);

  const { addProductToBasket, removeProductFromBasket } = useProductActions();
  const productsInBasket = useProductsInBasket();

  const onAddToBasketPress = React.useCallback(
    (product: Product) => () => {
      if (
        productsInBasket.find(
          (productInBasket) => productInBasket.product.id === product.id
        )
      ) {
        removeProductFromBasket(product.id);
      } else {
        addProductToBasket(product);
      }
    },
    [addProductToBasket, productsInBasket, removeProductFromBasket]
  );

  const onProductCardPress = React.useCallback(
    (productId: number) => () => {
      navigation.navigate(RouteNames.productDetail, { id: productId });
    },
    [navigation]
  );

  const renderItemSeparator = () => (
    <Spacing
      backgroundColor={DefaultTheme.colors.background}
      height={COMMON_STYLES.screenPadding}
    />
  );

  const getKeyExtractor = (item: Product) => item.id.toString();

  const renderItem = ({ item: product }: ListRenderItemInfo<Product>) => {
    return (
      <ProductListCard
        {...product}
        testID={`product-list-card-${product.id}`}
        basketButtonTestID={`basket-button-${product.id}`}
        isInBasket={
          typeof productsInBasket.find(
            (productInBasket) => productInBasket.product.id === product.id
          ) !== "undefined"
        }
        onAddToBasketPress={onAddToBasketPress(product)}
        onPress={onProductCardPress(product.id)}
      />
    );
  };

  if (isLoading) {
    return <ScreenLoading />;
  }

  return (
    <SafeAreaView style={COMMON_STYLES.flex} edges={["bottom"]}>
      {isError && (
        <View style={COMMON_STYLES.flexCenter}>
          <Text>An error occurred</Text>
        </View>
      )}

      {isSuccess && (
        <FlatList<Product>
          data={data}
          testID="product-list-flat-list"
          refreshControl={
            <RefreshControl
              refreshing={isRefetchingByUser}
              onRefresh={refetchByUser}
            />
          }
          renderItem={renderItem}
          numColumns={2}
          ItemSeparatorComponent={renderItemSeparator}
          columnWrapperStyle={styles.columnWrapper}
          contentContainerStyle={styles.contentContainerStyle}
          showsVerticalScrollIndicator={false}
          keyExtractor={getKeyExtractor}
          style={COMMON_STYLES.flex}
        />
      )}
    </SafeAreaView>
  );
};

export default ProductListScreen;

const styles = StyleSheet.create({
  contentContainerStyle: {
    flexGrow: 1,
    padding: COMMON_STYLES.screenPadding,
  },
  columnWrapper: {
    justifyContent: "space-between",
  },
});

This screen is responsible for the followings:

  • Displaying products with the custom react query hook, useGetAllProducts , which we created in 1.4 — Fetching products using react-query section.

  • Allowing users to add or remove products from their basket using the useProductActions custom zustand hook which again we have covered in 1.7 — Here comes zustand, managing products in the basket section.

  • Refetching products using pull-to-refresh.

  • Updating the quantity text displayed in the header right basket icon.

Below is the demonstration of product list screen:

2.1 — Testing product list screen

In this section, we will be conducting testing on product list screen. The initial test cases that will be carried out are as follows:

  • Initially, the loading indicator should be displayed, even though we mock the data. The isLoading boolean flag in react-query will still be set to truefor a short period of time. After the data is fetched, isLoading will be false, and the loader will not be displayed.

  • The product list should be displayed accurately after the data has been fetched. The testID prop of the ProductListCardcomponent will be utilized to verify this case.

  • In the event of a failed query to get all products, the screen should display an error message. We will be utilizing the setupGetAllProductsFailedHandler helper that was created in 1.9 — Mocking API data with MSW

2.1.1 —Test case: It should display loading indicator initially

import { renderHook } from "@testing-library/react-hooks";
import { render, screen } from "@testing-library/react-native";
import React from "react";
import { useGetAllProducts } from "../../api/product";
import { createReactQueryWrapper } from "../../utils/testing";
import ProductListScreen from "../product-list";

const navigateMock = jest.fn();
const navigation = { navigate: navigateMock } as any;
const route = jest.fn() as any;

const component = <ProductListScreen navigation={navigation} route={route} />;

describe("Product list screen", () => {
  it("should display loading indicator initially", async () => {
    // We render the component and expect to see a loading indicator
    render(component, { wrapper: createReactQueryWrapper });
    expect(screen.queryByTestId(`screen-loader`)).toBeTruthy();

    // We render the product list using useGetAllProducts hook and wait until the products are loaded
    const { result, waitFor } = renderHook(() => useGetAllProducts(), {
      wrapper: createReactQueryWrapper,
    });
    await waitFor(() => result.current.isSuccess);

    // We expect the loading indicator to disappear after the products are loaded
    expect(screen.queryByTestId(`screen-loader`)).not.toBeTruthy();
  });
});

At the top of the file, we’ve imported the necessary modules in order to run our tests. And then we created a variable called as component to store our product list screen, so that we can reuse this variable throughout our test cases. Furthermore, since it is a screen, and we declared screen props in section 2 — Product list screen overview, and in line 22, we also need to provide the required screen props, navigation and route. We will come back in upcoming test cases on why we destructed the navigation object in the above.

A short review of terminology we need to take care of in order to understand test cases:

  • render: Deeply renders given React element and returns helpers to query the output components structure.

  • screen: Hold the value of latest render call for easier access to query and other functions returned by render.

  • renderHook : Renders a test component that will call the provided callback, including any hooks it calls, every time it renders.

    Since the primary objective of this article is to provide guidance on how to conduct testing rather than an introduction to testing libraries, and their functions, we will skip diving into deep detail about them. We will have enough examples to see their usage anyway throughout the article.

The test case above checks if a screen loader component is displayed initially on the screen. The render function renders the ProductListScreen component wrapped in the createReactQueryWrapper function, which is a simple utility function used for creating QueryClientProvider . The screen.queryByTestId() function checks if the screen loader is displayed on the screen.

The renderHook function is used to test the useGetAllProducts hook, which is used in the ProductListScreen component to fetch the products data. The wrapper option is passed to the renderHook function to wrap the useGetAllProducts hook in the createReactQueryWrapper function. The result variable contains the value returned by the useGetAllProducts hook. Meanwhile, the waitFor function waits until the isSuccess property of the result object is true, which means that the products data has been successfully fetched.

The second part checks if the screen loader component is no longer displayed after the product data has been successfully fetched.

The reason we used queryByTestId instead of getByTestId is because we know that screen loader component will not be visible after data has been fetched, thus using getByTestId would throw an error.

The error we would be facing could be the following in case of using getByTestId:

2.1.2 — Test case: It should display product list data correctly

import { renderHook } from "@testing-library/react-hooks";
import { render, screen } from "@testing-library/react-native";
import React from "react";
import { useGetAllProducts } from "../../api/product";
import { createReactQueryWrapper } from "../../utils/testing";
import ProductListScreen from "../product-list";

const navigateMock = jest.fn();
const navigation = { navigate: navigateMock } as any;
const route = jest.fn() as any;

const component = <ProductListScreen navigation={navigation} route={route} />;

describe("Product list screen", () => {
  it("should display product list data correctly", async () => {
    // Render the component and wait for it to load
    render(component, { wrapper: createReactQueryWrapper });

    // Check that no product card is rendered before fetching data
    expect(screen.queryByTestId(`product-list-card-1`)).not.toBeTruthy();

    // Fetch the product data
    const { result, waitFor } = renderHook(() => useGetAllProducts(), {
      wrapper: createReactQueryWrapper,
    });

    // Wait for the data to be fetched successfully
    await waitFor(() => result.current.isSuccess);

    // Check that each product card is rendered for each product in the data
    for (const { id } of result.current.data!) {
      expect(screen.queryByTestId(`product-list-card-${id}`)).toBeTruthy();
    }
  });
});

This test code tests whether the product list screen displays the fetched product data correctly.

This time again we wait for data to be fetched successfully, then we are ensuring product list data is displayed correctly by looping through the fetched data and expecting that a product list card with the testID of product-list-card-${id} exists for each product in the data. The id property is dynamically generated based on the product ID. If the test fails, it means that the product list items are not displaying the product data correctly.

Notice how we were using the renderItem function in order to render list items below. We loop through product.id and combine with the string product-list-card to generate a dynamic testID for the ProductListCard component. In this way, we will be able to test all of the products along with their respective testID prop.

const renderItem = ({ item: product }: ListRenderItemInfo<Product>) => {
  return (
    <ProductListCard
      {...product}
      testID={`product-list-card-${product.id}`}
      basketButtonTestID={`basket-button-${product.id}`}
      isInBasket={
        typeof productsInBasket.find(
          (productInBasket) => productInBasket.product.id === product.id
        ) !== "undefined"
      }
      onAddToBasketPress={onAddToBasketPress(product)}
      onPress={onProductCardPress(product.id)}
    />
  );
};

2.1.3— Test case: It should display error text in case get all products query fails

In this test case, we are testing the scenario where the API call to fetch all products fails.

import { renderHook } from "@testing-library/react-hooks";
import { render, screen } from "@testing-library/react-native";
import React from "react";
import { setupGetAllProductsFailedHandler } from "../../../__mocks__/msw/handlers";
import { useGetAllProducts } from "../../api/product";
import { createReactQueryWrapper } from "../../utils/testing";
import ProductListScreen from "../product-list";

const navigateMock = jest.fn();
const navigation = { navigate: navigateMock } as any;
const route = jest.fn() as any;

const component = <ProductListScreen navigation={navigation} route={route} />;

describe("Product list screen", () => {
  it("should display error text in case get all products query fails", async () => {
    // Set up the mock handler for GET requests to the /products endpoint that returns an error response
    setupGetAllProductsFailedHandler();

    // Render the ProductListScreen component wrapped in the React Query wrapper
    render(component, { wrapper: createReactQueryWrapper });

    // Set up a mock React hook that calls the useGetAllProducts hook from the API module
    const { result, waitFor } = renderHook(() => useGetAllProducts(), {
      wrapper: createReactQueryWrapper,
    });

    // Wait for the useGetAllProducts hook to throw an error
    await waitFor(() => result.current.isError);

    // Assert that the "An error occurred" text is displayed on the screen
    expect(screen.getByText(`An error occurred`)).toBeTruthy();
  });
});

To simulate API failure, we use the setupGetAllProductsFailedHandler function from the mock server worker (msw), which we implement 1.9 — Mocking API data with MSW section in line 38. We then render the ProductListScreen component using the render function, and invoke the useGetAllProducts hook which fetches the data using the renderHook function.

We use the waitFor function from @testing-library/react-hooks to wait for the isError property of the result.current object to become true. This ensures that the hook has completed executing and the API call has failed. Finally, we assert that the error message “An error occurred” which you can double check by taking a look at the source code of the product list screen will be displayed on the screen by checking if the text is present using the getByText function from @testing-library/react-native.

2.1.4 — Test case: It should call navigation action on pressing the first product item

We will test the following code snippet which is actually extracted from our product list screen. This code is responsible for handling user press action to any of the product list card in the list.

const onProductCardPress = React.useCallback(
  (productId: number) => () => {
    navigation.navigate(RouteNames.productDetail, { id: productId });
  },
  [navigation]
);

const renderItem = ({ item: product }: ListRenderItemInfo<Product>) => {
  return (
    <ProductListCard
      {...product}
      testID={`product-list-card-${product.id}`}
      basketButtonTestID={`basket-button-${product.id}`}
      isInBasket={
        typeof productsInBasket.find(
          (productInBasket) => productInBasket.product.id === product.id
        ) !== "undefined"
      }
      onAddToBasketPress={onAddToBasketPress(product)}
      onPress={onProductCardPress(product.id)}
    />
  );
};

Let’s see below how we can approach testing it:

import { renderHook } from "@testing-library/react-hooks";
import { fireEvent, render, screen } from "@testing-library/react-native";
import React from "react";
import { useGetAllProducts } from "../../api/product";
import { RouteNames } from "../../navigation/route-names";
import { createReactQueryWrapper } from "../../utils/testing";
import ProductListScreen from "../product-list";

const navigateMock = jest.fn();
const navigation = { navigate: navigateMock } as any;
const route = jest.fn() as any;

const component = <ProductListScreen navigation={navigation} route={route} />;

describe("Product list screen", () => {
  it("should call navigation action on pressing the first product item", async () => {
    // render ProductListScreen component with createReactQueryWrapper as a wrapper
    render(component, { wrapper: createReactQueryWrapper });

    // render hook for useGetAllProducts with createReactQueryWrapper as a wrapper
    const { result, waitFor } = renderHook(() => useGetAllProducts(), {
      wrapper: createReactQueryWrapper,
    });

    // wait for useGetAllProducts to succeed
    await waitFor(() => result.current.isSuccess);

    // find first product item and simulate a press event on it
    const firstProductItem = screen.getByTestId(`product-list-card-1`);
    fireEvent.press(firstProductItem);

    // assert that navigation action was called with correct route name and params
    expect(navigateMock).toHaveBeenCalledWith(RouteNames.productDetail, {
      id: 1,
    });
  });
});

In this test code snippet, we are testing whether the navigation action is called when a product item is pressed. First, we render the ProductListScreen component and wait for data to be fetched successfully just like in previous test cases. Afterward, we retrieve the first product item from the list and simulate a press event on it using fireEvent.press. Finally, we expect that the navigate function of the navigation object has been called with the expected RouteName and productId parameters.

2.1.5 — Test case: It should add/remove product item correctly on pressing product item’s basket icon

The test case below is testing the scenario where the user adds and removes a product from the basket by pressing the basket icon on the product card on the Product List screen.

import { NavigationContainer } from "@react-navigation/native";
import { renderHook } from "@testing-library/react-hooks";
import { fireEvent, render, screen } from "@testing-library/react-native";
import React from "react";
import { useGetAllProducts } from "../../api/product";
import ProductStack from "../../navigation/product-stack";
import { useProductStore } from "../../store/product";
import { createReactQueryWrapper } from "../../utils/testing";

// We render the whole app stack instead of rendering just the screen
// because we need access to the react-navigation's header, which wouldn't
// be possible if we just rendered the screen.
const rootAppComponent = (
  <NavigationContainer>
    <ProductStack />
  </NavigationContainer>
);

describe("Product list screen", () => {
  it("should add/remove product item correctly on pressing product items basket icon", async () => {
    // We render the entire app stack and use the createReactQueryWrapper as a
    // wrapper for the render function to set up the React Query Provider.
    render(rootAppComponent, {
      wrapper: createReactQueryWrapper,
    });

    // We use the renderHook function to invoke the useGetAllProducts hook which
    // fetches the data.
    const { result, waitFor } = renderHook(() => useGetAllProducts(), {
      wrapper: createReactQueryWrapper,
    });

    // We use the renderHook function to get access to the useProductStore hook
    // which we will use to check that the products in the basket were added/removed correctly.
    const { result: productStore } = renderHook(() => useProductStore(), {
      wrapper: createReactQueryWrapper,
    });

    // We wait for the useGetAllProducts hook to complete fetching the data before proceeding
    // with the test.
    await waitFor(() => result.current.isSuccess);

    // We get the basket button for the first product item using the getByTestId function.
    const firstProductItemBasketButton = screen.getByTestId(`basket-button-1`);

    // We click the basket button for the first product item.
    fireEvent.press(firstProductItemBasketButton);

    // We check that the basket icon quantity text is present using the getByTestId function.
    expect(screen.getByTestId("basket-icon-quantity-text-1")).toBeTruthy();

    // We check that the products in the basket have been added correctly using the productStore.
    expect(productStore.current.productsInBasket).toHaveLength(1);
    expect(productStore.current.productsInBasket[0].quantity).toBe(1);
    expect(productStore.current.productsInBasket[0].product).toMatchObject(
      result.current.data![0]
    );

    // We click the basket button for the first product item again to remove it from the basket.
    fireEvent.press(firstProductItemBasketButton);

    // We check that the basket icon quantity text is not present.
    expect(
      screen.queryByTestId("basket-icon-quantity-text-1")
    ).not.toBeTruthy();

    // We check that the products in the basket have been removed correctly using the productStore.
    expect(productStore.current.productsInBasket).toHaveLength(0);
  });
});

In order to test the functionality of the basket icon on the header, we need to render the entire ProductStack in addition to the ProductListScreen. This is because we need to access the header of the Product List screen to get the basket icon by its test ID. This requires us to have the entire ProductStackrendered, along with the NavigationContainer .

After we are done with fetching products successfully, it uses fireEvent.press to simulate pressing the basket icon on the first product item, which adds the product to the basket. We then check that the basket icon displays the correct quantity, and that the product is added to the productsInBasket array in the product store with the correct quantity and product object.

After that, we do simulate pressing the basket icon again to remove the product from the basket, and check that the basket icon no longer displays the quantity, and that the productsInBasket array in the product store is empty.

In summary, this test case is testing the functionality of adding and removing a product from the basket as well as adjusting their quantities by pressing the basket icon on the product card on the Product List screen, and confirms that the product store correctly manages the products and their quantities in the basket.

2.1.6 — Product list screen complete test flow

The complete test flow of the product list screen is as follows:

import { NavigationContainer } from "@react-navigation/native";
import { renderHook } from "@testing-library/react-hooks";
import { fireEvent, render, screen } from "@testing-library/react-native";
import React from "react";
import { setupGetAllProductsFailedHandler } from "../../../__mocks__/msw/handlers";
import { useGetAllProducts } from "../../api/product";
import ProductStack from "../../navigation/product-stack";
import { RouteNames } from "../../navigation/route-names";
import { useProductStore } from "../../store/product";
import { createReactQueryWrapper } from "../../utils/testing";
import ProductListScreen from "../product-list";

// We render the whole app stack instead of rendering just the screen
// because we need access to the react-navigation's header, which wouldn't
// be possible if we just rendered the screen.
const rootAppComponent = (
  <NavigationContainer>
    <ProductStack />
  </NavigationContainer>
);
const navigateMock = jest.fn();
const navigation = { navigate: navigateMock } as any;
const route = jest.fn() as any;

const component = <ProductListScreen navigation={navigation} route={route} />;

describe("Product list screen", () => {
  it("should display loading indicator initially", async () => {
    // We render the component and expect to see a loading indicator
    render(component, { wrapper: createReactQueryWrapper });
    expect(screen.queryByTestId(`screen-loader`)).toBeTruthy();

    // We render the product list using useGetAllProducts hook and wait until the products are loaded
    const { result, waitFor } = renderHook(() => useGetAllProducts(), {
      wrapper: createReactQueryWrapper,
    });
    await waitFor(() => result.current.isSuccess);

    // We expect the loading indicator to disappear after the products are loaded
    expect(screen.queryByTestId(`screen-loader`)).not.toBeTruthy();
  });

  it("should display product list data correctly", async () => {
    // Render the component and wait for it to load
    render(component, { wrapper: createReactQueryWrapper });

    // Check that no product card is rendered before fetching data
    expect(screen.queryByTestId(`product-list-card-1`)).not.toBeTruthy();

    // Fetch the product data
    const { result, waitFor } = renderHook(() => useGetAllProducts(), {
      wrapper: createReactQueryWrapper,
    });

    // Wait for the data to be fetched successfully
    await waitFor(() => result.current.isSuccess);

    // Check that each product card is rendered for each product in the data
    for (const { id } of result.current.data!) {
      expect(screen.queryByTestId(`product-list-card-${id}`)).toBeTruthy();
    }
  });

  it("should display error text in case get all products query fails", async () => {
    // Set up the mock handler for GET requests to the /products endpoint that returns an error response
    setupGetAllProductsFailedHandler();

    // Render the ProductListScreen component wrapped in the React Query wrapper
    render(component, { wrapper: createReactQueryWrapper });

    // Set up a mock React hook that calls the useGetAllProducts hook from the API module
    const { result, waitFor } = renderHook(() => useGetAllProducts(), {
      wrapper: createReactQueryWrapper,
    });

    // Wait for the useGetAllProducts hook to throw an error
    await waitFor(() => result.current.isError);

    // Assert that the "An error occurred" text is displayed on the screen
    expect(screen.getByText(`An error occurred`)).toBeTruthy();
  });

  it("should call navigation action on pressing the first product item", async () => {
    // Render the ProductListScreen component along with react-query wrapper
    render(component, { wrapper: createReactQueryWrapper });

    // Render the useGetAllProducts hook with react-query wrapper to fetch data
    const { result, waitFor } = renderHook(() => useGetAllProducts(), {
      wrapper: createReactQueryWrapper,
    });

    // Wait for the data to be fetched
    await waitFor(() => result.current.isSuccess);

    // Find the first product card in the list and simulate a press event on it
    const firstProductItem = screen.getByTestId(`product-list-card-1`);
    fireEvent.press(firstProductItem);

    // Expect that the navigation function is called with the correct route name and params
    expect(navigateMock).toHaveBeenCalledWith(RouteNames.productDetail, {
      id: 1,
    });
  });

  it("should add/remove product item correctly on pressing product items basket icon", async () => {
    // We render the entire app stack and use the createReactQueryWrapper as a
    // wrapper for the render function to set up the React Query Provider.
    render(rootAppComponent, {
      wrapper: createReactQueryWrapper,
    });

    // We use the renderHook function to invoke the useGetAllProducts hook which
    // fetches the data.
    const { result, waitFor } = renderHook(() => useGetAllProducts(), {
      wrapper: createReactQueryWrapper,
    });

    // We use the renderHook function to get access to the useProductStore hook
    // which we will use to check that the products in the basket were added/removed correctly.
    const { result: productStore } = renderHook(() => useProductStore(), {
      wrapper: createReactQueryWrapper,
    });

    // We wait for the useGetAllProducts hook to complete fetching the data before proceeding
    // with the test.
    await waitFor(() => result.current.isSuccess);

    // We get the basket button for the first product item using the getByTestId function.
    const firstProductItemBasketButton = screen.getByTestId(`basket-button-1`);

    // We click the basket button for the first product item.
    fireEvent.press(firstProductItemBasketButton);

    // We check that the basket icon quantity text is present using the getByTestId function.
    expect(screen.getByTestId("basket-icon-quantity-text-1")).toBeTruthy();

    // We check that the products in the basket have been added correctly using the productStore.
    expect(productStore.current.productsInBasket).toHaveLength(1);
    expect(productStore.current.productsInBasket[0].quantity).toBe(1);
    expect(productStore.current.productsInBasket[0].product).toMatchObject(
      result.current.data![0]
    );

    // We click the basket button for the first product item again to remove it from the basket.
    fireEvent.press(firstProductItemBasketButton);

    // We check that the basket icon quantity text is not present.
    expect(
      screen.queryByTestId("basket-icon-quantity-text-1")
    ).not.toBeTruthy();

    // We check that the products in the basket have been removed correctly using the productStore.
    expect(productStore.current.productsInBasket).toHaveLength(0);
  });
});

Wrapping up

To conclude, this article has explored effective testing strategies for React Native applications that use React-Query and Zustand libraries which are pretty popular these days in any React/React Native applications. By using a simple store app as an example and providing comprehensive and detailed examples, we have covered the testing process and how to ensure code quality through the implementation of tests. Feel free to take a look at the full source code using the link below. Have a good day!

Full source code link

Published on

This article is also published on: