Integration Testing React Native Apps with GraphQL, Redux, Mock Service Worker Part II
“ In the majority of scenarios, the end-user does not know, and does not care whether Redux is used within the application at all. As such, the Redux code can be treated as an implementation detail of the app, without requiring explicit tests for the Redux code in many circumstances.”
– Redux Testing Guiding Principles
Part I: https://bit.ly/3m7ytAz
Github Repo (checkout the input-field-tests
branch): https://github.com/rosen777/RNTestLibraryApp
This quote is taken straight from the guiding principles in the official Redux documentation. And if you think about it, it makes sense. The integration tests as we discussed in Part I, differ from the unit tests because you are not testing values in isolation. You are testing based on what the user sees and interacts with on the screen. Therefore, the integration tests in many situations are agnostic to your state management library and care about the data being displayed and the sequence in which this displaying of data happens from the moment that a screen renders.
Setting up Redux
Run the following command in the project root folder:
yarn add react-redux
The slices in redux are files that contain the initial state, the reducer logic, and the actions being dispatched on the screens or pages to change this logic. We can handily use the createSlice
function that in Redux Toolkit automatically generates action type strings, action creator functions, and action objects. In our case, we have the following values we want to store in our initialState
name, age, and phone number. We would set up three actions that set the value of the payload to the corresponding value in the userSlice’s state: setUserName, setUserAge, setUserPhone. In src,
create a folder called redux
, and in the redux
folder create a subfolder called slices
. In that subfolder create a file called userSlice.js
with the code below.
import {createSlice} from '@reduxjs/toolkit';
const initialState = {
name: '',
age: '',
phone: '',
};
export const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
setUserName: (state, action) => {
state.name = action.payload;
},
setUserAge: (state, action) => {
state.age = action.payload;
},
setUserPhone: (state, action) => {
state.phone = action.payload;
},
},
});
export const {setUserName, setUserAge, setUserPhone} = userSlice.actions;
export default userSlice.reducer;
The next step would be to configure our redux store. In the src
folder, create another folder called store.js
The Redux documentation states that a store is an object that holds the application’s state tree. Each app has only one Redux store. The reducers and middleware need to be imported in the configureStore
function. We do not have middleware so we only import the reducers.
import {configureStore} from '@reduxjs/toolkit';
import userReducer from '../redux/slices/userSlice';
export const store = configureStore({
reducer: {
user: userReducer,
},
});
Apollo GraphQL
Let’s build a fun new feature. Let’s build a dropdown menu that displays different flags and country codes to append to our new phone number field. In the root folder of your project run the following command:
yarn add @apollo/client graphql cross-fetch
yarn add react-native-dropdown-picker
It is now time to set up our Apollo GraphQL client, which builds a supergraph that is a unified network of all your data, services, and capabilities that connects to your application clients. The data from our GraphQL requests is fetched using queries. The URL that we will use is a free GraphQL URL handily provided to use: https://countries.trevorblades.com. Unlike REST URLs, when you open the URL in a browser, you will see a playground where you can make different queries and mutations. Visit the URL and run the following query:
query GetCountries {
countries {
name
emoji
phone
}
}
In addition to visualizing the data in a nice and neat manner, using GraphQL queries with the Apollo Client has the following advantages: automatic local caching for query results, updating the queries with up-to-date server data using polling and refetching, providing error and loading states. In our src folder of the app, create a new subfolder called services. In the services subfolder, create a index.js
file:
import {ApolloClient, InMemoryCache, HttpLink} from '@apollo/client';
import fetch from 'cross-fetch';
// Initialize Apollo Client
export const client = new ApolloClient({
link: new HttpLink({uri: 'https://countries.trevorblades.com', fetch}),
cache: new InMemoryCache(),
});
In the services
subfolder, create another subfolder called countries
. This is where we will make our query. We will create a getCountries.js
file that will host our query.
import {gql} from '@apollo/client';
export const getCountries = gql`
query GetCountries {
countries {
name
emoji
phone
}
}
`;
Adding the UI Logic
Now that we have Redux and the Apollo GraphQL client setup, it is time to make the following changes to our src/User.js
file: add the getCountries
query, add the dropdown picker with data from the query, and add a Submit button that submits the user’s age, name, and phone number to our userSlice
.
In the handleTransformDropdown
function, we set the initial value of the dropdown menu to the UK country code (+44) and we also set the values to our dropdown items based on the values fetched from our URL.
const handleTransformDropdown = countriesData => {
if (countriesData && items.length === 0) {
const dropdownData = countriesData.countries.reduce(
(acc, currValue, currIndex) => {
if (
!acc.includes({
value: currValue.phone,
label: `${currValue.emoji} +${currValue.phone}`,
index: currIndex.toString(),
countryCode: currValue.phone,
})
) {
if (currValue.phone === '44') {
setValue(currValue.phone);
}
acc.push({
value: currValue.phone,
label: `${currValue.emoji} +${currValue.phone}`,
index: currIndex.toString(),
countryCode: currValue.phone,
});
}
return acc;
},
[],
);
setItems(dropdownData);
}
};
When fetching our countries using the getCountries
query, we will make use of the loading property by displaying a loading text, if the data is being fetched. These are the snippets of the code relevant to our data fetching, and the full code for the User file is at the end of this section.
import {getCountries} from './services/countries/getCountries';
...
useEffect(() => {
if (loading) {
setIsLoading(true);
} else {
setIsLoading(false);
if (error) {
setCountriesError(error);
}
if (data) {
setCountries(data);
}
}
}, [loading, data, error]);
useEffect(() => {
handleTransformDropdown(data);
}, [data]);
...
return (
<SafeAreaView>
{isLoading ? (
<View style={styles.loadingWrapper}>
<Text style={styles.loadingText}>Loading...</Text>
</View>
) : (
...
We will be using useSelector
hook to read our state values from the Redux store. In our case, these values are user.name, user.age, user.phone. We will also be using the useDispatch
hook to dispatch our actions from the userSlice
. These actions will send an action object containing a payload to change the state values from the Redux store above. The actions we dispatch are setUserName, setUserAge, setUserPhone. We dispatch these actions in the handleSubmit
function when the Submit button is pressed.
import React, {useState, useEffect} from 'react';
import {
SafeAreaView,
View,
Text,
StyleSheet,
TextInput,
Button,
} from 'react-native';
import {useSelector, useDispatch} from 'react-redux';
import {setUserName, setUserAge, setUserPhone} from './redux/slices/userSlice';
import {useQuery} from '@apollo/client';
import {getCountries} from './services/countries/getCountries';
import DropDownPicker from 'react-native-dropdown-picker';
const User = () => {
const username = useSelector(state => state.user.name);
const userage = useSelector(state => state.user.age);
const userPhone = useSelector(state => state.user.phone);
const dispatch = useDispatch();
const [isLoading, setIsLoading] = useState(false);
const [openCountries, setOpenCountries] = useState(false);
const {loading, error, data} = useQuery(getCountries);
const [items, setItems] = useState([]);
const [value, setValue] = useState('');
const [countries, setCountries] = useState([]);
const [countriesError, setCountriesError] = useState();
const [phone, setPhone] = useState('');
const [name, setName] = useState('');
const [age, setAge] = useState('');
useEffect(() => {
if (loading) {
setIsLoading(true);
} else {
setIsLoading(false);
if (error) {
setCountriesError(error);
}
if (data) {
setCountries(data);
}
}
}, [loading, data, error]);
useEffect(() => {
handleTransformDropdown(data);
}, [data]);
const onChangeUserName = name => {
dispatch(setUserName(name));
};
const onChangeUserAge = age => {
dispatch(setUserAge(age));
};
const onChangePhoneNumber = phoneString => {
const phoneWithCountryCode = `${value}${phoneString}`;
dispatch(setUserPhone(phoneWithCountryCode));
};
const handleSubmit = () => {
onChangeUserName(name);
onChangeUserAge(age);
onChangePhoneNumber(phone);
};
const handleTransformDropdown = countriesData => {
if (countriesData && items.length === 0) {
const dropdownData = countriesData.countries.reduce(
(acc, currValue, currIndex) => {
if (
!acc.includes({
value: currValue.phone,
label: `${currValue.emoji} +${currValue.phone}`,
index: currIndex.toString(),
countryCode: currValue.phone,
})
) {
if (currValue.phone === '44') {
setValue(currValue.phone);
}
acc.push({
value: currValue.phone,
label: `${currValue.emoji} +${currValue.phone}`,
index: currIndex.toString(),
countryCode: currValue.phone,
});
}
return acc;
},
[],
);
setItems(dropdownData);
}
};
return (
<SafeAreaView>
{isLoading ? (
<View style={styles.loadingWrapper}>
<Text style={styles.loadingText}>Loading...</Text>
</View>
) : (
<View style={styles.wrapper}>
<Text style={styles.text}>Your name is </Text>
<Text style={styles.text}>{username}</Text>
<TextInput
style={styles.input}
onChangeText={text => setName(text)}
value={name}
placeholder="Name"
testID="App.username"
/>
<Text style={styles.text}>Your age is </Text>
<Text testID="userage.text" style={styles.text}>
{userage}
</Text>
<TextInput
style={styles.input}
onChangeText={text => setAge(text)}
value={age}
placeholder="Userage"
testID="App.userage"
/>
<View>
<Text style={styles.text}>Your phone is </Text>
<Text testID="App.userphone" style={styles.text}>
{userPhone}
</Text>
<View style={styles.dropDownWrapper}>
<DropDownPicker
open={openCountries}
value={value}
items={items}
onPress={setOpenCountries}
setOpen={setOpenCountries}
setValue={setValue}
setItems={setItems}
placeholder="Country"
labelProps={{
numberOfLines: 1,
}}
testID="App.CountryPicker"
/>
<TextInput
style={styles.fullInput}
onChangeText={text => setPhone(text)}
value={phone}
placeholder="Phone"
testID="App.phone"
keyboardType="phone-pad"
/>
</View>
</View>
<View>
<Button title="Submit" onPress={() => handleSubmit()} />
</View>
</View>
)}
</SafeAreaView>
);
};
export default User;
const styles = StyleSheet.create({
input: {
margin: 12,
borderWidth: 1,
padding: 10,
},
fullInput: {
height: 40,
margin: 12,
borderWidth: 1,
padding: 10,
width: 230,
bottom: 8,
},
inputContainer: {
justifyContent: 'flex-start',
flexGrow: 1,
},
loadingText: {
fontSize: 18,
fontWeight: 'bold',
},
loadingWrapper: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
text: {
fontSize: 14,
marginLeft: 20,
},
dropDownWrapper: {
marginHorizontal: 12,
marginTop: 20,
maxWidth: 130,
flexDirection: 'row',
},
wrapper: {
flexGrow: 1,
marginTop: 20,
justifyContent: 'space-around',
},
submitButton: {
border: 0,
lingHeight: 2.5,
paddingVertical: 20,
width: 80,
alignItems: 'center',
borderRadius: 8,
backgroundColor: '#7393B3',
},
});
Integration Testing
Finally, we get to the main point of this tutorial, integration testing. Unlike Part I, this time we have an API request and we would need to intercept this request and display mock data in our tests. In order to do this, we will set up a mock service worker (msw). In our src
folder, create a new folder called mocks. Here we will create two files, handlers.js
and testAppServer.js
First, in our testAppServer.js,
we will configure our servers and import the handlers. The beforeAll function establishes an API mocking before all tests, the afterEach resets our request handlers so they don’t affect other tests, the afterAll function cleans up after our tests are finished.
import {setupServer} from 'msw/node';
import {handlers} from './handlers';
// This configures a Service Worker with the given request handlers.
export const server = setupServer(...handlers);
export const setupTestAppServer = () => {
beforeAll(() => {
server.listen({
onUnhandledRequest(req) {
console.error(
'Found an unhandled %s request to %s',
req.method,
req.url.href,
);
},
});
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => server.close());
};
Next, we need to set up our request handlers. According to the official Mock Service Worker documentation, a request handler is a function that determines whether an outgoing request should be mocked and specifies its mocked response. In order for our integration tests to work we need to keep the same data structure. However, we will use a small array of only three country objects. In our handlers.js
write the following:
import {graphql} from 'msw';
export const handlers = [
graphql.query('GetCountries', (req, res, ctx) => {
return res(
ctx.data({
countries: [
{
name: 'United Kingdom',
emoji: '🇬🇧',
phone: '44',
__typename: 'Country',
},
{
name: 'France',
emoji: '🇫🇷',
phone: '33',
__typename: 'Country',
},
{
name: 'United States',
emoji: '🇺🇸',
phone: '1',
__typename: 'Country',
},
],
}),
);
}),
];
Finally, we get to write our tests. In our App.test.js
file, we will import our setupTestAppServer so that it intercepts the requests in our tests.
import {setupTestAppServer} from '../src/mocks/testAppServer';
setupTestAppServer();
afterEach(() => {
jest.clearAllMocks();
});
Do we need to set up our Redux Provider and store? Remember our quote at the beginning of the post that “in the majority of scenarios, the end-user does not know, and does not care whether Redux is used within the application at all”. Our integration tests are meant to test how the application’s components work together based on the way our users interact with the app. If we dispatch data and use it in a separate screen mostly, we would need to wrap our test component in a provider and import a store with a mock initial state. However, since we use the input fields already on the screen to dispatch our actions and we display the data from the state of the Redux store in Text nodes on the same screen, we will use the UI elements in our tests instead.
First, we will write our test for the name. Let’s think about what happens on the screen when the user wants to have their name displayed. The first thing that happens is that the user selects the input field with testID “App.username”. The user types their name in that field. The user clicks on the Submit button and the name appears in a Text node. Let’s put this in an integration test.
test('Displays a name, if the name is inputted and the user clicks on the Submit button', async () => {
const INPUT_TEXT = 'John';
render(component);
const nameTextInput = await screen.findByTestId('App.username');
fireEvent.changeText(nameTextInput, INPUT_TEXT);
const submitButton = screen.getByText('Submit');
fireEvent.press(submitButton);
const usernameText = screen.getByText('John');
expect(usernameText).toBeDefined();
});
The second test we will write is for the age field. What happens when the user wants their age displayed? Almost the same thing with the exception that the user selects the input field with testID “App.userage” and types their age in that field.
test('Displays an age, if the age is inputted and the user clicks on the Submit button', async () => {
const INPUT_TEXT = '35';
render(component);
const ageTextInput = await screen.findByTestId('App.userage');
fireEvent.changeText(ageTextInput, INPUT_TEXT);
const submitButton = screen.getByText('Submit');
fireEvent.press(submitButton);
const userageText = screen.getByText('35');
expect(userageText).toBeDefined();
});
Finally, we will write a test for the phone number. To display the phone number, we again await the input field to be displayed on the screen by using findByTestId
. The RNTL documentation states that findBy*
queries return a promise which resolves when a matching element is found. The promise is rejected if the element is not found. In contrast, the getBy*
queries return the first matching node and throw an error if no matching element is found. The user then inputs their phone number in the input field. When the user presses the submit button, the text node where the phone number appears should have the default country code (44) appended to it. This country code comes from the mock response we set up in our handlers. If you remove the first object in the countries array in the src/mocks/handlers.js
file, the test would fail as the phone number would be displayed without a country code in the text node. It is time to commit and push to a new branch called intermediate-rntl-2
. If you are having issues with the code this branch contains the completed version.
test('Displays a phone number when the country is selected from the dropdown, a phone number is inputted, and the submit button is pressed', async () => {
const INPUT_TEXT = '7491111111';
render(component);
const phoneTextInput = await screen.findByTestId('App.phone');
fireEvent.changeText(phoneTextInput, INPUT_TEXT);
const submitButton = screen.getByText('Submit');
fireEvent.press(submitButton);
const phoneText = screen.getByText('447491111111');
expect(phoneText).toBeDefined();
});
This is how our complete App.test.js
file should look like this.
import React from 'react';
import 'react-native';
import App from '../App';
import {render, fireEvent, screen} from '@testing-library/react-native';
import {setupTestAppServer} from '../src/mocks/testAppServer';
setupTestAppServer();
afterEach(() => {
jest.clearAllMocks();
});
const component = <App />;
describe('Check input field data', () => {
test('Displays a name, if the name is inputted and the user clicks on the Submit button', async () => {
const INPUT_TEXT = 'John';
render(component);
const nameTextInput = await screen.findByTestId('App.username');
fireEvent.changeText(nameTextInput, INPUT_TEXT);
const submitButton = screen.getByText('Submit');
fireEvent.press(submitButton);
const usernameText = screen.getByText('John');
expect(usernameText).toBeDefined();
});
test('Displays an age, if the age is inputted and the user clicks on the Submit button', async () => {
const INPUT_TEXT = '35';
render(component);
const ageTextInput = await screen.findByTestId('App.userage');
fireEvent.changeText(ageTextInput, INPUT_TEXT);
const submitButton = screen.getByText('Submit');
fireEvent.press(submitButton);
const userageText = screen.getByText('35');
expect(userageText).toBeDefined();
});
test('Displays a phone number when the country is selected from the dropdown, a phone number is inputted, and the submit button is pressed', async () => {
const INPUT_TEXT = '7491111111';
render(component);
const phoneTextInput = await screen.findByTestId('App.phone');
fireEvent.changeText(phoneTextInput, INPUT_TEXT);
const submitButton = screen.getByText('Submit');
fireEvent.press(submitButton);
const phoneText = screen.getByText('447491111111');
expect(phoneText).toBeDefined();
});
});
This is how our app should work.
And the src
folder of our project should have the following files (the src/services/countries/getCountry.js
file is optional as it is another GraphQL query that we are not using in this tutorial).
Conclusion
Voilà our tests should pass now. Pat yourself on the back for what has been achieved so far. Think about what other tests you can write. For instance, what if the request fails, can we provide error response resolvers in our src/mocks/handlers.js
file?