Unit tests with react-native testing library and Apollo Graphql
Tags: reactnative, testing, tutorial
Published: 2021-06-27 23:51:24

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](https://dev.to/komyg/creating-an-app-using-react-and-apollo-graphql-1ine) 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](https://dev.to/harrisonhenri/creating-a-react-native-app-using-apollo-graphql-v3-gj5).

#### Configuring React-native Testing Lybrary

In this tutorial we are going to use [React-native testing lybrary](https://testing-library.com/docs/react-native-testing-library/intro/) and Jest to run unit tests on our code. First, let's install the dependency:

```
yarn add @testing-library/react-native
```

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"
    ],
```

## Testing the components

Now that we have the [React-native testing lybrary](https://testing-library.com/docs/react-native-testing-library/intro/) 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',
  },
});
```

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,
};

```

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,
  },
});
```

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)',
            },
          },
        ],
      },
    },
  },
};
```

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,
  },
});
```

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(),
    ]);
  });
});
```

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,
  },
};
```

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!