Skip to content

Unit Tests

As we mention in the overview page of the testing section, we include the Jest testing framework and the React Native Testing Library for unit tests, along with mocks for most libraries.

The following guide is not a tutorial on how to write tests using React Native Testing Library and Jest, but rather a guide on how to write tests with this starter and some best practices to follow. If you are not familiar with testing, we recommend reading the official documentation of Jest and React Native Testing Library to get familiar with them.

Also worth mentioning that we should aim to test the following:

  • Business logic: Test component and function utilities that contain business logic. Form validation, data manipulation and calculations, etc.

  • Complex components: Test components that contain complex logic. For example, components that contain a lot of conditional rendering, or components that contain a lot of state management logic.

Writing tests

Let’s start by writing a simple test for our login screen. We will test the following login form component:

./src/components/login-form.tsx
import { zodResolver } from '@hookform/resolvers/zod';
import React from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
import * as z from 'zod';
import { Button, ControlledInput, Text, View } from '@/components/ui';
const schema = z.object({
name: z.string().optional(),
email: z
.string({
required_error: 'Email is required',
})
.email('Invalid email format'),
password: z
.string({
required_error: 'Password is required',
})
.min(6, 'Password must be at least 6 characters'),
});
export type FormType = z.infer<typeof schema>;
export type LoginFormProps = {
onSubmit?: SubmitHandler<FormType>;
};
export const LoginForm = ({ onSubmit = () => {} }: LoginFormProps) => {
const { handleSubmit, control } = useForm<FormType>({
resolver: zodResolver(schema),
});
return (
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior="padding"
keyboardVerticalOffset={10}
>
<View className="flex-1 justify-center p-4">
<View className="items-center justify-center">
<Text
testID="form-title"
className="pb-6 text-center text-4xl font-bold"
>
Sign In
</Text>
<Text className="mb-6 max-w-xs text-center text-gray-500">
Welcome! 👋 This is a demo login screen! Feel free to use any email
and password to sign in and try it out.
</Text>
</View>
<ControlledInput
testID="name"
control={control}
name="name"
label="Name"
/>
<ControlledInput
testID="email-input"
control={control}
name="email"
label="Email"
/>
<ControlledInput
testID="password-input"
control={control}
name="password"
label="Password"
placeholder="***"
secureTextEntry={true}
/>
<Button
testID="login-button"
label="Login"
onPress={handleSubmit(onSubmit)}
/>
</View>
</KeyboardAvoidingView>
);
};

Now, let’s write a test for the login form component. We will test the following:

  • The form renders correctly.
  • Show the correct error messages on invalid or missing data.
  • Submit the form with valid data and make sure that the onSubmit function is called with the correct data.

First, let’s create a new file called login-form.test.tsx in the src/screens/login directory. Then, add the following code to it:

./src/components/login-form.test.tsx
import React from 'react';
import { cleanup, screen, setup, waitFor } from '@/lib/test-utils';
import type { LoginFormProps } from './login-form';
import { LoginForm } from './login-form';
afterEach(cleanup);
const onSubmitMock: jest.Mock<LoginFormProps['onSubmit']> = jest.fn();
describe('LoginForm Form ', () => {
it('renders correctly', async () => {
setup(<LoginForm />);
expect(await screen.findByTestId('form-title')).toBeOnTheScreen();
});
it('should display required error when values are empty', async () => {
const { user } = setup(<LoginForm />);
const button = screen.getByTestId('login-button');
expect(screen.queryByText(/Email is required/i)).not.toBeOnTheScreen();
await user.press(button);
expect(await screen.findByText(/Email is required/i)).toBeOnTheScreen();
expect(screen.getByText(/Password is required/i)).toBeOnTheScreen();
});
it('should display matching error when email is invalid', async () => {
const { user } = setup(<LoginForm />);
const button = screen.getByTestId('login-button');
const emailInput = screen.getByTestId('email-input');
const passwordInput = screen.getByTestId('password-input');
await user.type(emailInput, 'yyyyy');
await user.type(passwordInput, 'test');
await user.press(button);
expect(await screen.findByText(/Invalid Email Format/i)).toBeOnTheScreen();
expect(screen.queryByText(/Email is required/i)).not.toBeOnTheScreen();
});
it('Should call LoginForm with correct values when values are valid', async () => {
const { user } = setup(<LoginForm onSubmit={onSubmitMock} />);
const button = screen.getByTestId('login-button');
const emailInput = screen.getByTestId('email-input');
const passwordInput = screen.getByTestId('password-input');
await user.type(emailInput, '[email protected]');
await user.type(passwordInput, 'password');
await user.press(button);
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalledTimes(1);
});
// expect.objectContaining({}) because we don't want to test the target event we are receiving from the onSubmit function
expect(onSubmitMock).toHaveBeenCalledWith(
{
password: 'password',
},
expect.objectContaining({}),
);
});
});

As you may notice from the code, we are importing a bunch of things from the @/lib/test-utils directory. This is a simple file that exports everything from the @testing-library/react-native library and overrides the render function to wrap the component with the providers we need. This way, we don’t have to import the providers in every test file.

Now that we have our test file ready, let’s run it and see what happens. To run the test, run the following command:

Terminal window
pnpm test
pnpm test:watch # To run the tests in watch mode

Tests on CI with GitHub actions

It’s important to run tests on CI in addition to local testing. This ensures that our code doesn’t break when we push it to Github. We have added a GitHub action that runs tests for every push to the main branch or new pull request. It reports the results to GitHub through annotations and provides a summary of the tests along with coverage.

Here is an example of the output of the GitHub action:

GitHub action output

More tests

For more complex logic and components, we recommend taking a look at this amazing project which provides a lot of examples and best practices for testing React Native apps using React Native Testing Library and Jest:

⚡️ React Native Testing

React Native Testing Library Cookbook