DEV Community

loading...

Unit tests with react-native testing library and Apollo Graphql

Harrison Henri dos Santos Nascimento
・7 min read

In this tutorial we will build a react-native shopping cart app using the version 3 of Apollo Graphql. This tutorial is based on these great three part series of articles focusing on Apollo v3 and a basic structure of projects using this technology stack.

Note: this tutorial assumes that you have a working knowledge of React-native, typescript and node.

The final source code of this tutorial can be obtained by acessing https://github.com/HarrisonHenri/rick-morty-react-native-shop.

Getting started

This tutorial begin at code generated Part 1.

Configuring React-native Testing Lybrary

In this tutorial we are going to use React-native testing lybrary and Jest to run unit tests on our code. First, let's install the dependency:

yarn add @testing-library/react-native
Enter fullscreen mode Exit fullscreen mode

Then at package.json we add this two fields at jest configuration:

    "transformIgnorePatterns": [
      "node_modules/(?!@react-native|react-native)"
    ],
    "setupFiles": [
      "./node_modules/react-native-gesture-handler/jestSetup.js"
    ],
Enter fullscreen mode Exit fullscreen mode

Testing the components

Now that we have the React-native testing lybrary installed and configured, we are able to start to test our components.

Character card

To test our CharacterCard.tsx, first we add the testID property to each RectButton at src/common/components/CharacterCard.tsx:

import React from 'react';
import {Image, StyleSheet, Text, View} from 'react-native';
import {RectButton} from 'react-native-gesture-handler';
import Icon from 'react-native-vector-icons/Entypo';
import {useUpdateChosenQuantity} from '../hooks/use-update-chosen-quantity';

interface Props {
  data: {
    id?: string | null;
    image?: string | null;
    name?: string | null;
    unitPrice?: number;
    chosenQuantity?: number;
  };
}

const CharacterCard: React.FC<Props> = ({data}) => {
  const {onIncreaseChosenQuantity, onDecreaseChosenQuantity} =
    useUpdateChosenQuantity();

  return (
    <View style={styles.container}>
      {data.image && <Image source={{uri: data.image}} style={styles.image} />}
      <View style={styles.details}>
        <Text style={styles.text}>{data.name}</Text>
        <Text style={styles.text}>{`U$ ${data.unitPrice}`}</Text>
      </View>
      <View style={styles.choseQuantityContainer}>
        <RectButton
          testID="charactter-remove-btn"
          onPress={onDecreaseChosenQuantity.bind(null, data.id as string)}>
          <Icon name="minus" size={24} color="#3D7199" />
        </RectButton>
        <Text style={styles.choseQuantityText}>{data.chosenQuantity}</Text>
        <RectButton
          testID="charactter-add-btn"
          onPress={onIncreaseChosenQuantity.bind(null, data.id as string)}>
          <Icon name="plus" size={24} color="#3D7199" />
        </RectButton>
      </View>
    </View>
  );
};

export default CharacterCard;

const styles = StyleSheet.create({
  container: {
    width: '100%',
    borderRadius: 20,
    marginVertical: 8,
    paddingHorizontal: 8,
    paddingVertical: 24,
    backgroundColor: '#F0F0F0',
    flexDirection: 'row',
  },
  image: {width: 70, height: 70},
  details: {
    marginLeft: 8,
    justifyContent: 'space-between',
    flex: 1,
  },
  text: {
    fontSize: 16,
    fontWeight: 'bold',
  },
  choseQuantityContainer: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'space-between',
    flexDirection: 'row',
  },
  choseQuantityText: {
    padding: 8,
    borderRadius: 8,
    backgroundColor: '#fff',
    fontSize: 16,
    fontWeight: 'bold',
  },
});
Enter fullscreen mode Exit fullscreen mode

Now at tests/CharacterCard.spec.tsx we write:

import {fireEvent, render} from '@testing-library/react-native';
import React from 'react';
import * as useUpdateChosenQuantityModule from '../src/common/hooks/use-update-chosen-quantity';
import CharacterCard from '../src/common/components/CharacterCard';

describe('CharacterCard component', () => {
  beforeEach(() => {
    jest
      .spyOn(useUpdateChosenQuantityModule, 'useUpdateChosenQuantity')
      .mockReturnValue({
        onIncreaseChosenQuantity: jest.fn(),
        onDecreaseChosenQuantity: jest.fn(),
      });
  });
  it('should render', () => {
    const wrapper = render(<CharacterCard data={mockData} />);
    expect(wrapper).toBeTruthy();
  });
  it('should call onIncreaseChosenQuantity and onDecreaseChosenQuantity on press', () => {
    const mockOnIncreaseChosenQuantity = jest.fn();
    const mockOnDecreaseChosenQuantity = jest.fn();
    jest
      .spyOn(useUpdateChosenQuantityModule, 'useUpdateChosenQuantity')
      .mockReturnValue({
        onIncreaseChosenQuantity: mockOnIncreaseChosenQuantity,
        onDecreaseChosenQuantity: mockOnDecreaseChosenQuantity,
      });
    const wrapper = render(<CharacterCard data={mockData} />);
    fireEvent.press(wrapper.getByTestId('charactter-remove-btn'));
    fireEvent.press(wrapper.getByTestId('charactter-add-btn'));
    expect(mockOnIncreaseChosenQuantity).toBeCalled();
    expect(mockOnDecreaseChosenQuantity).toBeCalled();
  });
});

const mockData = {
  id: 'any_id',
  image: 'any_image',
  name: 'any_name',
  unitPrice: 10,
  chosenQuantity: 0,
};

Enter fullscreen mode Exit fullscreen mode

Here, at begginig we test if the component render correctly. Then, after spying our local api, we ensure that each button calls the callbacks from the custom hook.

Home screen

Again, we add a testID at scr/screens/Home.tsx to help us:

import React from 'react';
import { ActivityIndicator, FlatList, StyleSheet, View } from 'react-native';
import { Character, useGetCharactersQuery } from '../common/generated/graphql';

import CharacterCard from '../common/components/CharacterCard';

const Home = () => {
  const { data, loading } = useGetCharactersQuery();

  if (loading) {
    return (
      <View testID="progress" style={styles.container}>
        <ActivityIndicator color="#32B768" size="large" />
      </View>
    );
  }

  return (
    <View style={styles.container} testID="container">
      <FlatList
        data={data?.characters?.results}
        renderItem={({ item }) => <CharacterCard data={item as Character} />}
        contentContainerStyle={styles.characterList}
      />
    </View>
  );
};

export default Home;

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#FFFFFF',
  },
  characterList: {
    padding: 16,
  },
});
Enter fullscreen mode Exit fullscreen mode

Then, we test(tests/Home.spec.tsx):

import {render, waitFor} from '@testing-library/react-native';
import React from 'react';
import {MockedProvider} from '@apollo/client/testing';
import {GetCharactersDocument} from '../src/common/generated/graphql';
import Home from '../src/screens/Home';

describe('Home component', () => {
  it('should render and show progress on loading', () => {
    const wrapper = render(
      <MockedProvider addTypename={false} mocks={[mock]}>
        <Home />
      </MockedProvider>,
    );

    expect(wrapper.queryByTestId('progress')).toBeTruthy();
  });
  it('should render the flatlist when whe loading is false', async () => {
    const wrapper = render(
      <MockedProvider addTypename={false} mocks={[mock]}>
        <Home />
      </MockedProvider>,
    );

    await waitFor(() => [
      expect(wrapper.queryByTestId('progress')).toBeFalsy(),
    ]);

    expect(wrapper.queryByTestId('container')?.children.length).toBe(1);
  });
});

const mock = {
  request: {
    query: GetCharactersDocument,
  },
  result: {
    data: {
      characters: {
        __typename: 'Characters',
        results: [
          {
            id: '1',
            __typename: 'Character',
            name: 'Rick Sanchez',
            image: 'https://rickandmortyapi.com/api/character/avatar/1.jpeg',
            species: 'Human',
            unitPrice: 1,
            chosenQuantity: 10,
            origin: {
              id: '1',
              __typename: 'Location',
              name: 'Earth (C-137)',
            },
            location: {
              id: '20',
              __typename: 'Location',
              name: 'Earth (Replacement Dimension)',
            },
          },
        ],
      },
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

At this test we use the MockedProvider to, first, ensure that at loading we render the ActivityIndicator. Then, we ensure that when the data is available, we render the view properly with only one character (since the data array has only one entry).

Cart screen

Adding our testID at scr/screens/Cart.tsx:

import React, { useCallback } from 'react';
import { useNavigation } from '@react-navigation/native';
import { StyleSheet, Text, View, SafeAreaView, Button } from 'react-native';
import { useGetShoppingCartQuery } from '../common/generated/graphql';

const Cart = () => {
  const navigation = useNavigation();
  const { data } = useGetShoppingCartQuery();

  const handleNavigation = useCallback(() => {
    navigation.navigate('Home');
  }, [navigation]);

  return (
    <SafeAreaView style={styles.container}>
      {data?.shoppingCart?.numActionFigures ? (
        <>
          <View style={styles.content} testID="fulfilled-cart">
            <Text style={styles.emoji}>🤗</Text>
            <Text
              style={
                styles.subtitle
              }>{`Total number of items: ${data?.shoppingCart.numActionFigures}`}</Text>
            <Text
              style={
                styles.subtitle
              }>{`Total price: U$ ${data?.shoppingCart.totalPrice}`}</Text>
          </View>
        </>
      ) : (
        <>
          <View style={styles.content} testID="empty-cart">
            <Text style={styles.emoji}>😢</Text>
            <Text style={styles.title}>Empty cart!</Text>
            <View style={styles.footer}>
              <Button title="Go back to shop" onPress={handleNavigation} />
            </View>
          </View>
        </>
      )}
    </SafeAreaView>
  );
};

export default Cart;

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
  content: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    width: '100%',
  },
  title: {
    fontSize: 24,
    marginTop: 15,
    lineHeight: 32,
    textAlign: 'center',
  },
  subtitle: {
    fontSize: 16,
    lineHeight: 32,
    marginTop: 8,
    textAlign: 'center',
    paddingHorizontal: 20,
  },
  emoji: {
    fontSize: 44,
    textAlign: 'center',
  },
  footer: {
    width: '100%',
    paddingHorizontal: 20,
  },
});
Enter fullscreen mode Exit fullscreen mode

Then(tests/Cart.spec.tsx):

import {fireEvent, render, waitFor} from '@testing-library/react-native';
import React from 'react';
import {MockedProvider} from '@apollo/client/testing';
import Cart from '../src/screens/Cart';

const mockedNavigate = jest.fn();

jest.mock('@react-navigation/native', () => ({
  useNavigation: () => ({navigate: mockedNavigate}),
}));

describe('Cart component', () => {
  it('should render with empty cart and navigate on click', async () => {
    const resolvers = {
      Query: {
        shoppingCart: jest.fn().mockReturnValue(null),
      },
    };

    const wrapper = render(
      <MockedProvider resolvers={resolvers} addTypename={false} mocks={[]}>
        <Cart />
      </MockedProvider>,
    );

    await waitFor(() => [
      expect(wrapper.queryByTestId('empty-cart')).toBeTruthy(),
      expect(wrapper.queryByTestId('fulfilled-cart')).toBeFalsy(),
    ]);

    fireEvent.press(wrapper.getByRole('button'));
    expect(mockedNavigate).toHaveBeenCalledWith('Home');
  });
  it('should render when the shoppingCart cart is fulfilled', async () => {
    const resolvers = {
      Query: {
        shoppingCart: jest.fn().mockReturnValue({
          id: 'any_id',
          totalPrice: 10,
          numActionFigures: 10,
        }),
      },
    };

    const wrapper = render(
      <MockedProvider resolvers={resolvers} addTypename={false} mocks={[]}>
        <Cart />
      </MockedProvider>,
    );

    await waitFor(() => [
      expect(wrapper.queryByTestId('empty-cart')).toBeFalsy(),
      expect(wrapper.queryByTestId('fulfilled-cart')).toBeTruthy(),
    ]);
  });
});
Enter fullscreen mode Exit fullscreen mode

Here, different from the home screen test, instead of using the mock of the query result we use resolvers, since this is a local query. The two cases of use tested are:

  • If the cart is empty, we show a button to allow the user go back to the home screen.
  • If the cart is fulfilled we show the cart content.

Testing the custom hook

To test our use-update-chosen-quantity.ts we write:

import {fireEvent, render} from '@testing-library/react-native';
import React from 'react';
import {MockedProvider} from '@apollo/client/testing';
import {ApolloClient, gql, InMemoryCache} from '@apollo/client';
import {useUpdateChosenQuantity} from '../src/common/hooks/use-update-chosen-quantity';
import {Button} from 'react-native';
import {
  CharacterDataFragment,
  CharacterDataFragmentDoc,
  GetShoppingCartDocument,
  GetShoppingCartQuery,
} from '../src/common/generated/graphql';

describe('useUpdateChosenQuantity hook', () => {
  let cache: InMemoryCache;
  let client: ApolloClient<any>;

  beforeEach(() => {
    cache = new InMemoryCache();

    cache.writeQuery({
      query: mockCharactersQuery,
      data: mockCharactersData,
    });

    client = new ApolloClient({
      cache,
    });
  });

  it('should increase the quantity correctly', async () => {
    const MockComponent = () => {
      const {onIncreaseChosenQuantity} = useUpdateChosenQuantity();

      return (
        <Button
          title="any_title"
          onPress={() => onIncreaseChosenQuantity('1')}
        />
      );
    };

    const wrapper = render(
      <MockedProvider cache={cache}>
        <MockComponent />
      </MockedProvider>,
    );

    fireEvent.press(wrapper!.getByRole('button')!);
    fireEvent.press(wrapper!.getByRole('button')!);

    const shoopingCart = client.readQuery<GetShoppingCartQuery>({
      query: GetShoppingCartDocument,
    });

    const character = client.readFragment<CharacterDataFragment>({
      fragment: CharacterDataFragmentDoc,
      id: 'Character:1',
    });

    expect(shoopingCart?.shoppingCart?.numActionFigures).toBe(2);
    expect(shoopingCart?.shoppingCart?.totalPrice).toBe(20);
    expect(character?.chosenQuantity).toBe(2);
  });
  it('should decrease the quantity correctly', async () => {
    cache.writeQuery({
      query: mockShoppingCartQuery,
      data: mockShoppinData,
    });

    client = new ApolloClient({
      cache,
    });

    const MockComponent = () => {
      const {onDecreaseChosenQuantity} = useUpdateChosenQuantity();

      return (
        <Button
          title="any_title"
          onPress={() => onDecreaseChosenQuantity('2')}
        />
      );
    };

    const wrapper = render(
      <MockedProvider cache={cache}>
        <MockComponent />
      </MockedProvider>,
    );

    fireEvent.press(wrapper!.getByRole('button')!);
    fireEvent.press(wrapper!.getByRole('button')!);
    fireEvent.press(wrapper!.getByRole('button')!);
    fireEvent.press(wrapper!.getByRole('button')!);
    fireEvent.press(wrapper!.getByRole('button')!);

    const shoopingCart = client.readQuery<GetShoppingCartQuery>({
      query: GetShoppingCartDocument,
    });

    const character = client.readFragment<CharacterDataFragment>({
      fragment: CharacterDataFragmentDoc,
      id: 'Character:2',
    });

    expect(shoopingCart?.shoppingCart?.numActionFigures).toBe(0);
    expect(shoopingCart?.shoppingCart?.totalPrice).toBe(0);
    expect(character?.chosenQuantity).toBe(0);
  });
});

const mockCharactersQuery = gql`
  fragment characterData on Character {
    id
    __typename
    name
    unitPrice @client
    chosenQuantity @client
  }

  query GetCharacters {
    characters {
      __typename
      results {
        ...characterData
        image
        species
        origin {
          id
          __typename
          name
        }
        location {
          id
          __typename
          name
        }
      }
    }
  }
`;

const mockCharactersData = {
  characters: {
    __typename: 'Characters',
    results: [
      {
        id: '1',
        __typename: 'Character',
        name: 'Rick Sanchez',
        image: 'https://rickandmortyapi.com/api/character/avatar/1.jpeg',
        species: 'Human',
        unitPrice: 10,
        chosenQuantity: 0,
        origin: {
          id: '1',
          __typename: 'Location',
          name: 'Earth (C-137)',
        },
        location: {
          id: '20',
          __typename: 'Location',
          name: 'Earth (Replacement Dimension)',
        },
      },
      {
        id: '2',
        __typename: 'Character',
        name: 'Rick Sanchez',
        image: 'https://rickandmortyapi.com/api/character/avatar/1.jpeg',
        species: 'Human',
        unitPrice: 10,
        chosenQuantity: 1,
        origin: {
          id: '1',
          __typename: 'Location',
          name: 'Earth (C-137)',
        },
        location: {
          id: '20',
          __typename: 'Location',
          name: 'Earth (Replacement Dimension)',
        },
      },
    ],
  },
};

const mockShoppingCartQuery = gql`
  query GetShoppingCart {
    shoppingCart @client {
      id
      totalPrice
      numActionFigures
    }
  }
`;

const mockShoppinData = {
  shoppingCart: {
    id: 'ShoppingCart:1',
    totalPrice: 10,
    numActionFigures: 1,
  },
};
Enter fullscreen mode Exit fullscreen mode

Here it was presented a lot of code, so let's carve up what we are really doing:

  • At bottom of the file we add some gql to mock our local queries and results.
  • Before each test we write the caracters data into local cache.
  • Then, when testing each callback, we check the state of the cache after executing the hook to both: character fragments and the shopping cart.

Conclusion

If everything goes fine, when running yarn test all tests should pass!. Again, I'll be happy if you could provide me any feedback about the code, structure, doubt or anything that could make me a better developer!

Discussion (0)