Robust React Native App — 2

Abdullah Süha ışık
12 min readJul 15, 2023

In this section, we will be writing many unit tests. Let’s begin with what we see on the first screen. Usually, I complete writing the component before moving on to writing the unit test.

When you open the app, the onboarding screens introduce you. Therefore, we can start by writing a unit test for that component.

import {View} from 'react-native';
import React from 'react';
import AppIntroSlider from '../components/AppIntro/AppIntroSlider';

export type appIntroScreenType = {
title: Array<string>;
img: any;
content: string;
};

const appIntroScreensArray: Array<appIntroScreenType> = [
...
];

const Onboarding: React.FC<{}> = () => {
return (
<View style={{flex: 1}}>
<AppIntroSlider appIntroScreensArray={appIntroScreensArray} />
</View>
);
};

export default Onboarding;

The Onboarding component is a fundamental container component that creates an array of elements to be rendered in the AppIntroScreen component. This array is then passed as a prop to the AppIntroSlider component

When writing unit tests, developers sometimes overthink the process. However, it is not necessary to delve too deeply into the details when writing unit tests.

We can check if AppIntro renders correctly. Can I locate my components using the visual rendering mechanism in Jest? That should be sufficient. To achieve this, I need to add testId to the parent elements of my React Native components.

<View style={{flex: 1}} testId = "Onboarding" />

Now I can check my element via getByTestId (matcher) method.

As you can see, the Onboarding component imports the AppIntroSlider component, as mentioned in the first article. For proper unit testing, it is recommended to isolate each test case within its own lifecycle. To achieve this, I can mock the AppIntroSlider component using a Jest mock function, which simplifies the testing process.

jest.mock('../../components/AppIntro/AppIntroSlider', () => 'MockedAppIntroSlider');
import React from 'react';
import { render } from '@testing-library/react-native';
import Onboarding from '../Onboarding';


jest.mock('../../components/AppIntro/AppIntroSlider', () => 'MockedAppIntroSlider'); // Mock the AppIntroSlider component

describe('Onboarding', () => {
it('renders the component correctly', () => {
const { getByTestId } = render(<Onboarding />);
const onboardingComponent = getByTestId('Onboarding')
expect(onboardingComponent).toBeDefined();
});
});

That’s it, Onboarding components unit test is done.

Onboarding test passes

We continue AppIntroSlider. Create a new file under components/AppIntro name __tests__ and create AppIntroSlider.test.tsx .

AppIntroSlider Components View
import React, { FC, useRef, useState } from 'react';

import {
Dimensions,
NativeScrollEvent,
NativeSyntheticEvent,
ScrollView,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import AppIntroScreen from './AppIntroScreen';
import { COLORS } from '../../assets';
import AppIntroFooter from './AppIntroFooter';
import { useNavigation } from '@react-navigation/native';
import { AuthenticationNavigationProp } from '../../navigations/AuthenticationNavigator/types';
import { appIntroScreenType } from '../../screens/Onboarding';

type AppIntroSliderProps = {
appIntroScreensArray: Array<appIntroScreenType>;
};

const AppIntroSlider: FC<AppIntroSliderProps> = ({ appIntroScreensArray }) => {
....
function handleSignInButton() {
....
}

function handleScrollTo(destination: number) {
....
}

const setSliderPage = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
....
};

return (
<SafeAreaView style={{ backgroundColor: COLORS.appIntroBgColor, flex: 1 }}>
<ScrollView
horizontal={true}
scrollEventThrottle={16}
pagingEnabled={true}
showsHorizontalScrollIndicator={false}
onScroll={(event: NativeSyntheticEvent<NativeScrollEvent>) => {
setSliderPage(event);
}}
ref={sliderRef}
testID="onBoardingContainer"
>
{appIntroScreensArray.map((screen, index) => (
<AppIntroScreen width={width} screen={screen} key={index} />
))}
</ScrollView>
{/* FOOTER */}
<AppIntroFooter
sliderState={sliderState}
width={width}
appIntroScreensArray={appIntroScreensArray}
handleScrollTo={handleScrollTo}
handleSignInButton={handleSignInButton}
/>
</SafeAreaView>
);
};

export default AppIntroSlider;

That component includes a custom scroll view to display each page horizontally, with each page occupying the entire screen. The component receives an array of elements as props called appIntroScreensArray. The scroll view renders these elements in the AppIntroScreen component, with an AppIntroFooter component positioned at the bottom of the screen. I will assign a testID to the other component that will be mocked.

I realized that I forgot to add the testID prop type to the components. Furthermore, I have now updated the types and included the testID prop in the first elements of the component.

type AppIntroScreenProps = {
width: number;
screen: appIntroScreenType;
testID?: string
};

Apply the same improvements to the AppIntroFooter component.

<AppIntroScreen width={width} screen={screen} key={index} testID={"appIntroScreen"}  />

Here is refactored AppIntroSlider component's return function;


return (
<SafeAreaView style={{ backgroundColor: COLORS.appIntroBgColor, flex: 1}} testID='appIntroSlider'>
<ScrollView
horizontal={true}
scrollEventThrottle={16}
pagingEnabled={true}
showsHorizontalScrollIndicator={false}
onScroll={(event: NativeSyntheticEvent<NativeScrollEvent>) => {
setSliderPage(event);
}}
ref={sliderRef}
testID="onBoardingContainer"
>
{appIntroScreensArray.map((screen, index) => (
<AppIntroScreen width={width} screen={screen} key={index} testID={"appIntroScreen"} />
))}
</ScrollView>
{/* FOOTER */}
<AppIntroFooter
sliderState={sliderState}
width={width}
appIntroScreensArray={appIntroScreensArray}
handleScrollTo={handleScrollTo}
handleSignInButton={handleSignInButton}
testID={"appIntroFooter"}
/>
</SafeAreaView>
);
};

export default AppIntroSlider;

For other test examples, I plan to refactor the components by passing them if they require only minor code changes, such as adding a testId. So don’t worry if you don’t see testID on master branch.

The first test could be to verify these components render correctly.

  it('renders the component correctly', () => {

const appIntroScreensArray = [
{
title: ['Welcome to ', 'READDIT'],
img: 'image1',
content: 'Content 1',
},
{
title: ['Create an English ', 'Reading HABIT'],
img: 'image2',
content: 'Content 2',
},

const { getByTestId, getAllByTestId } = render(
<AppIntroSlider appIntroScreensArray={appIntroScreensArray} />
);

const appIntroSlider = getByTestId('appIntroSlider');
const appIntroScreens = getAllByTestId('appIntroScreen');
const appIntroFooter = getByTestId('appIntroFooter');

expect(appIntroSlider).toBeDefined();
expect(appIntroScreens.length).toBe(appIntroScreensArray.length);
expect(appIntroFooter).toBeDefined();
});
Above tests results

As you see, we got an error, we need to mock our navigation components, To mock the useNavigation hook, you can include the following lines at the top of the test file.

// Mocking the useNavigation hook
jest.mock('@react-navigation/native', () => ({
useNavigation: jest.fn(),
}));

Now it’s working. Another option is to add the above code snippets into the setup.ts file located in the test folder. I have opted for the second option.

My setup file is

import '@testing-library/jest-native/extend-expect';
// Mocking the useNavigation hook
jest.mock('@react-navigation/native', () => ({
useNavigation: jest.fn(),
}));

We can test the functions of the AppIntroSlider component in its children components, since AppIntroSlider passes its own functions to the children as props. This can serve as a hint when encountering difficulties in testing your functions. If you find the need to frequently perform such tests, it may indicate that a code refactor is necessary, as it could be a potential antipattern.

We can check appIntroScreen components screens count.

  it('renders the correct number of appIntroScreens', () => {
const { getAllByTestId } = render(<AppIntroSlider appIntroScreensArray={appIntroScreensArray} />);
const appIntroScreens = getAllByTestId('appIntroScreen');

expect(appIntroScreens.length).toBe(appIntroScreensArray.length);
});

The last version of AppIntroSlider.test.tsx

import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import AppIntroSlider from '../AppIntroSlider';

// jest.mock('../AppIntroScreen', () => 'MockedAppIntroScreen');
// jest.mock('../AppIntroFooter', () => 'MockedAppIntroFooter');

const appIntroScreensArray = [
{
title: ['Welcome to ', 'READDIT'],
img: 'image1',
content: 'Content 1',
},
{
title: ['Create an English ', 'Reading HABIT'],
img: 'image2',
content: 'Content 2',
},
];

describe('AppIntroSlider', () => {
it('renders the component correctly', () => {
const { getByTestId, getAllByTestId } = render(
<AppIntroSlider appIntroScreensArray={appIntroScreensArray} />
);

const appIntroSlider = getByTestId('appIntroSlider');
const appIntroScreens = getAllByTestId('appIntroScreen');
const appIntroFooter = getByTestId('appIntroFooter');

expect(appIntroSlider).toBeDefined();
expect(appIntroScreens.length).toBe(appIntroScreensArray.length);
expect(appIntroFooter).toBeDefined();
});

it('renders the correct number of appIntroScreens', () => {
const { getAllByTestId } = render(<AppIntroSlider appIntroScreensArray={appIntroScreensArray} />);
const appIntroScreens = getAllByTestId('appIntroScreen');
expect(appIntroScreens.length).toBe(appIntroScreensArray.length);
});

});

Instead of sending the testId prop, I suggest using a static testID by adding it to the root of the component. This approach eliminates the need for mocking components, as they have static testID values. Consequently, there is no requirement to update the AppIntroScreenProps and appIntroFooter types. This method provides greater convenience, and you only need to remove the lines:

jest.mock('../AppIntroScreen', () => 'MockedAppIntroScreen');
jest.mock('../AppIntroFooter', () => 'MockedAppIntroFooter');

That’s it.

    <View style={[styles.container, { width }]} testID={"appIntroFooter"}>
...
</View>
const AppIntroScreen: FC<AppIntroScreenProps> = ({ width, screen }) => {
return (
<View style={[styles.container, { width }]} testID={"appIntroScreen"}>
{/* HEADER */}
<View testID="welcomeHeader" style={styles.header}>
<Text style={styles.headerText}>{screen.title[0]}</Text>
<Text style={styles.headerText}>{screen.title[1]}</Text>
</View>
{/* IMAGE */}
<Image source={screen.img} style={styles.imageStyle} testID='appIntroImage'/>
{/* CONTENT */}
<View style={styles.wrapper}>
<Text style={[styles.headerText, styles.content]}>
You can help you{' '}
<Text style={{ color: COLORS.primary }}>to be a better</Text> version
of <Text style={{ color: COLORS.primary }}>yourself</Text>
</Text>
</View>
</View>
);
};

Now, let’s focus on the AppIntroScreen component, which is primarily responsible for rendering the props correctly. The AppIntroScreen component takes the AppIntroScreenProps as its props, with the screen property types defined as follows:

export type appIntroScreenType = {
title: Array<string>;
img: any;
content: string;
}; // exported in Onboarding.tsx
import React, { FC } from 'react';
import { Image, PixelRatio, StyleSheet, Text, View } from 'react-native';
import { COLORS, SIZES } from '../../assets';
import { appIntroScreenType } from '../../screens/Onboarding';

type AppIntroScreenProps = {
width: number;
screen: appIntroScreenType;
testID?: string
};

/**
* @param props - {@link AppIntroScreenProps}
*/

const AppIntroScreen: FC<AppIntroScreenProps> = ({ width, screen, testID }) => {
return (
<View style={[styles.container, { width }]} testID={testID}>
{/* HEADER */}
<View testID="welcomeHeader" style={styles.header}>
<Text style={styles.headerText}>{screen.title[0]}</Text>
<Text style={styles.headerText}>{screen.title[1]}</Text>
</View>
{/* IMAGE */}
<Image source={screen.img} style={styles.imageStyle} />
{/* CONTENT */}
<View style={styles.wrapper}>
<Text style={[styles.headerText, styles.content]}>
You can help you{' '}
<Text style={{ color: COLORS.primary }}>to be a better</Text> version
of <Text style={{ color: COLORS.primary }}>yourself</Text>
</Text>
</View>
</View>
);
};

const styles = StyleSheet.create({
...
});

export default AppIntroScreen;

We need to check props are rendering correctly ?

import React from 'react';
import { render } from '@testing-library/react-native';
import AppIntroScreen from '../AppIntroScreen';

// Mock the image module
// jest.mock('../../../assets/images/AppIntro/Habits.png', () => ({
// // Replace the module with a mocked value
// uri: 'mocked-image-uri',
// }));

describe('AppIntroScreen', () => {
const mockScreen = {
title: ['Title 1', 'Title 2'],
img: require('../../../assets/images/AppIntro/Habits.png'),
content: 'Some content'
};

it('renders correctly props', () => {
const { getByText, getByTestId } = render(<AppIntroScreen width={320} screen={mockScreen} />);
const titleElement1 = getByText('Title 1');
const titleElement2 = getByText('Title 2');
const imageElement = getByTestId('appIntroImage');

expect(imageElement).toBeDefined();
expect(imageElement.props.source).toEqual(mockScreen.img);
expect(titleElement1).toBeDefined();
expect(titleElement2).toBeDefined();
});

});

If you want, you can mock image path you can do with commented lines.

Now, let’s move on to the appIntroFooter component, which provides a more enjoyable experience due to its inclusion of logic.

appIntoFooter components view when pageIndex less than appIntroScreensArray
appIntoFooter components view when pageIndex more than appIntroScreensArray

The AppIntroFooter component demonstrates more logical behavior compared to the previously tested components. It determines which buttons to render based on the pageIndex value. When the pageIndex is greater than the length of the appIntroScreensArray minus one, indicating that we are on the last appIntro screen, it shows the login button. Conversely, it displays the Skip and Next buttons. Additionally, if the user wishes to skip the introduction screens, they can click the Skip button to directly access the last introduction screen, which features the sign-in button. Let’s proceed with testing all of these functionalities.

type appIntroButtonType = {
...
};

const AppIntroButton: FC<appIntroButtonType> = ({ title, handlePress }) => {
return (
...
);
};

type AppIntroFooterProps = {
sliderState: {
currentPage: number;
};
width: number;
appIntroScreensArray: Array<any>;
handleScrollTo: (destination: number) => void;
handleSignInButton: () => void;
testID: string
};

/**
* Component description
* @param props - {@link AppIntroFooterProps}
*/

const AppIntroFooter: FC<AppIntroFooterProps> = ({
sliderState,
width,
appIntroScreensArray,
handleScrollTo,
handleSignInButton,
testID
}) => {
const { currentPage: pageIndex } = sliderState;
function handlePress(destination: number): void {
// console.log(event);
handleScrollTo(destination);
}
return (
<View style={[styles.container, { width }]} testID={testID}>
{pageIndex >= appIntroScreensArray.length - 1 ? (
<View style={{ width: width - 20 }}>
<Button
title="Sign In"
handlePress={() => handleSignInButton()}
testID="signIn"
/>
</View>
) : (
<View style={[styles.container, { width }]}>
<AppIntroButton
handlePress={() => {
handlePress(appIntroScreensArray.length - 1);
}}
title="Skip"
/>
<View style={styles.paginationWrapper}>
{appIntroScreensArray.map((key, index) => (
<View
style={[
styles.paginationDots,
{ opacity: pageIndex === index ? 1 : 0.2 },
]}
key={index}
/>
))}
</View>
{/* handlePress(pageIndex) */}
<AppIntroButton
handlePress={() => {
handlePress(pageIndex + 1);
}}
title="Next"
handleSignInButton={handleSignInButton}
/>
</View>
)}
</View>
);
};

const styles = StyleSheet.create({
.....
});

export default AppIntroFooter;

Summarize test topics,

  • It should render the Sign In button on the last page
  • It should render the skip and next buttons on non-last page
  • it should call handleScrollTo when Skip button is pressed
  • it should call handleScrollTo when Next button is pressed
  • it should call handleSignInButton when Sign In button is pressed

We found 5 test case.

Button

type ButtonProps = {
title: string;
handlePress: () => void;
testID: string;
isLoading?: boolean;
};

/**
* @param props - {@link ButtonProps}
*/

const Button: FC<ButtonProps> = ({ title, handlePress, testID, isLoading }) => {
return (
<View style={styles.container}>
{isLoading ? (
<Spinner size={'large'} />
) : (
<TouchableOpacity
style={styles.button}
onPress={handlePress}
testID={testID}
>
<Text style={styles.buttonTitle}>{title}</Text>
</TouchableOpacity>
)}
</View>
);
};

For the button component, we can check bellows;

Firstly, mock some props

  const mockTitle = 'Submit';
const mockHandlePress = jest.fn();
const mockTestID = 'submitButton';
  • it should display title
  it('render correctly and displays the correct title', () => {
const { getByText } = render(
<Button title={mockTitle} handlePress={mockHandlePress} testID={mockTestID} />
);
const titleElement = getByText(mockTitle);
expect(titleElement).toBeDefined();
});
  • it should call when button tapped
  it('calls handlePress when button is pressed', () => {
const { getByTestId } = render(
<Button title={mockTitle} handlePress={mockHandlePress} testID={mockTestID} />
);
const button = getByTestId(mockTestID);
fireEvent.press(button);
expect(mockHandlePress).toHaveBeenCalled();
});
  • it should display spinner when isLoading is true
 it('displays the spinner when isLoading is true', () => {
const { getByTestId } = render(
<Button title={mockTitle} handlePress={mockHandlePress} testID={mockTestID} isLoading={true} />
);
const spinner = getByTestId('spinner');
expect(spinner).toBeDefined();
});
  • it should not display spinner when isLoading prop is false
  it('displays the button when isLoading is false', () => {
const { getByTestId, findByTestId, queryByTestId } = render(
<Button title={mockTitle} handlePress={mockHandlePress} testID={mockTestID} isLoading={false} />
);
const spinner = getByTestId('spinner');
expect(spinner).toBeNull();
});
Getting Error

If getByTestId does not find the element, it throws an error. In such cases, you can use queryByTestId or findByTestId instead.

  it('displays the button when isLoading is false', () => {
const { queryByTestId } = render(
<Button title={mockTitle} handlePress={mockHandlePress} testID={mockTestID} isLoading={false} />
);
const spinner = queryByTestId('spinner');
expect(spinner).toBeNull();
});

Icon

To test the Icon components, you will need to make some changes due to the usage of the react-native-svg package, which requires additional setup. Add the following lines to the setup.ts file.

//mock svgUri
jest.mock('react-native-svg', () => ({
SvgUri: 'SvgUri',
}));

module.exports = 'SvgMock';
module.exports.ReactComponent = 'SvgMock';

jest.config.json

{
"preset": "react-native",
"setupFilesAfterEnv": ["<rootDir>/src/tests/setup.ts"],
"transformIgnorePatterns": [
"node_modules/(?!(jest-)?@?react-native|@react-native-community|@react-navigation)"
],
"moduleNameMapper": {
"\\.svg": "<rootDir>/src/tests/setup.ts"
}
}
  • it should display when get link as a prop
  it('should render as link', () => {
const screen = render(<Icon src="https://placehold.co/600x400" />);
expect(screen).toBeDefined();
});
  • it should display when get link as a prop and width
  it('should render as link with width', () => {
const { queryByTestId } = render(
<Icon src="https://placehold.co/600x400" width={600} />,
);
const actual = queryByTestId('iconUri');
expect(actual).not.toBeNull();
});
  • it should display when get ID as a prop
  it('should render as id', () => {
const screen = render(<Icon id="user" />);
expect(screen).toBeDefined();
});

TextInput

type TextInputProps = {
placeHolder?: string;
setText: Dispatch<SetStateAction<string>>;
text: string;
color?: string;
icon?: (typeof ICON)[keyof typeof ICON];
testID: string;
};

/**
* Component description
* Resuable TextInput Component
* Color option
* @param props - {@link TextInputProps}
*/

const TextInput: React.FC<TextInputProps> = ({
placeHolder,
setText,
text,
color,
icon,
testID,
}) => {
function onChangeText(text: React.SetStateAction<string>) {
setText(text);
}
return (
<View
style={[
styles.container,
color ? { backgroundColor: color } : { backgroundColor: COLORS.white },
]}
testID={testID}
>
{icon ? (
<View style={styles.iconContainer}>
<Icon id={icon} width={'20'} height={'20'} />
</View>
) : null}
<ReactTextInput
style={[styles.input]}
onChangeText={(text: string) => onChangeText(text)}
value={text}
placeholder={placeHolder}
/>
</View>
);
};

const styles = StyleSheet.create({
...
});

export default TextInput;
  • it should display correctly with placeHolder
  • it should update text value when typing
  it('render correctly with placeHolder and should update text value when typing', () => {
const { getByPlaceholderText } = render(
<TextInput
placeHolder={placeHolder}
setText={setTextMock}
text=""
color={color}
testID={testID}
/>,
);

const inputElement = getByPlaceholderText(placeHolder);
fireEvent.changeText(inputElement, testText);
expect(setTextMock).toHaveBeenCalledWith(testText);
});
  • it should render an icon if the icon prop is provided

it('should render an icon if the icon prop is provided', () => {
const { getByTestId } = render(
<TextInput
placeHolder={placeHolder}
setText={setTextMock}
text={testText}
icon={testIcon}
testID={testID}
/>,
);
const iconElement = getByTestId(iconTestId);
expect(iconElement).toBeTruthy();
});
  • it should render with props bgColor if the color prop is provided
  it('should render with props bgColor if the color prop is provided', () => {
const { getByTestId } = render(
<TextInput
placeHolder={placeHolder}
setText={setTextMock}
text={testText}
icon={testIcon}
testID={testID}
color={color}
/>,
);
const textInput = getByTestId(testID).parent;
expect(textInput).toHaveStyle({ backgroundColor: color });
});

We have learned a lot so far, and you can continue on your own. The Login component has not been tested yet. It does not receive props directly; instead, it creates some functions and passes them as props to the ForgetPassword and SignIn components. You can test these functions by mocking them in the ForgetPassword and SignIn components. Additionally, you can check if the Login component renders correctly and displays the expected content.

I believe the examples mentioned above are sufficient for now. If you need further assistance, you can refer to the instructions provided earlier. You can find all the code changes and files at https://github.com/abdullahsuhaisk/robustReactNativeApp/tree/unitTest.

The next article will delve into E2E testing in React Native, focusing specifically on the DETOX testing framework.

--

--