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
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 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!
Top comments (0)