diff --git a/versioned_docs/version-7.x/testing.md b/versioned_docs/version-7.x/testing.md index 07b86a0e89..6ed74cb2b0 100644 --- a/versioned_docs/version-7.x/testing.md +++ b/versioned_docs/version-7.x/testing.md @@ -1,115 +1,763 @@ --- id: testing -title: Testing with Jest -sidebar_label: Testing with Jest +title: Writing tests +sidebar_label: Writing tests --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; -Testing code using React Navigation may require some setup since we need to mock native dependencies used in the navigators. We recommend using [Jest](https://jestjs.io) to write unit tests. +React Navigation components can be tested in a similar way to other React components. This guide will cover how to write tests for components using React Navigation using [Jest](https://jestjs.io). -## Mocking native modules +## Guiding principles + +When writing tests, it's encouraged to write tests that closely resemble how users interact with your app. Keeping this in mind, here are some guiding principles to follow: + +- **Test the result, not the action**: Instead of checking if a specific navigation action was called, check if the expected components are rendered after navigation. +- **Avoid mocking React Navigation**: Mocking React Navigation components can lead to tests that don't match the actual logic. Instead, use a real navigator in your tests. + +Following these principles will help you write tests that are more reliable and easier to maintain by avoiding testing implementation details. + +## Mocking native dependencies To be able to test React Navigation components, certain dependencies will need to be mocked depending on which components are being used. -If you're using `@react-navigation/drawer`, you will need to mock: +If you're using `@react-navigation/stack`, you will need to mock: -- `react-native-reanimated` - `react-native-gesture-handler` -If you're using `@react-navigation/stack`, you will only need to mock: +If you're using `@react-navigation/drawer`, you will need to mock: +- `react-native-reanimated` - `react-native-gesture-handler` To add the mocks, create a file `jest/setup.js` (or any other file name of your choice) and paste the following code in it: ```js -// include this line for mocking react-native-gesture-handler +// Include this line for mocking react-native-gesture-handler import 'react-native-gesture-handler/jestSetup'; -// include this section and the NativeAnimatedHelper section for mocking react-native-reanimated -jest.mock('react-native-reanimated', () => { - const Reanimated = require('react-native-reanimated/mock'); +// Include this section for mocking react-native-reanimated +import { setUpTests } from 'react-native-reanimated'; - // The mock for `call` immediately calls the callback which is incorrect - // So we override it with a no-op - Reanimated.default.call = () => {}; - - return Reanimated; -}); +setUpTests(); // Silence the warning: Animated: `useNativeDriver` is not supported because the native animated module is missing +import { jest } from '@jest/globals'; + jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper'); ``` -Then we need to use this setup file in our jest config. You can add it under `setupFiles` option in a `jest.config.js` file or the `jest` key in `package.json`: +Then we need to use this setup file in our jest config. You can add it under `setupFilesAfterEnv` option in a `jest.config.js` file or the `jest` key in `package.json`: ```json { "preset": "react-native", - "setupFiles": ["/jest/setup.js"] + "setupFilesAfterEnv": ["/jest/setup.js"] } ``` -Make sure that the path to the file in `setupFiles` is correct. Jest will run these files before running your tests, so it's the best place to put your global mocks. +Make sure that the path to the file in `setupFilesAfterEnv` is correct. Jest will run these files before running your tests, so it's the best place to put your global mocks. + +
+Mocking `react-native-screens` + +This shouldn't be necessary in most cases. However, if you find yourself in a need to mock `react-native-screens` component for some reason, you should do it by adding following code in `jest/setup.js` file: + +```js +// Include this section for mocking react-native-screens +jest.mock('react-native-screens', () => { + // Require actual module instead of a mock + let screens = jest.requireActual('react-native-screens'); + + // All exports in react-native-screens are getters + // We cannot use spread for cloning as it will call the getters + // So we need to clone it with Object.create + screens = Object.create( + Object.getPrototypeOf(screens), + Object.getOwnPropertyDescriptors(screens) + ); + + // Add mock of the component you need + // Here is the example of mocking the Screen component as a View + Object.defineProperty(screens, 'Screen', { + value: require('react-native').View, + }); + + return screens; +}); +``` + +
If you're not using Jest, then you'll need to mock these modules according to the test framework you are using. -## Writing tests +## Fake timers + +When writing tests containing navigation with animations, you need to wait until the animations finish. In such cases, we recommend using [`Fake Timers`](https://jestjs.io/docs/timer-mocks) to simulate the passage of time in your tests. This can be done by adding the following line at the beginning of your test file: + +```js +jest.useFakeTimers(); +``` + +Fake timers replace real implementation of the native timer functions (e.g. `setTimeout()`, `setInterval()` etc,) with a custom implementation that uses a fake clock. This lets you instantly skip animations and reduce the time needed to run your tests by calling methods such as `jest.runAllTimers()`. + +Often, component state is updated after an animation completes. To avoid getting an error in such cases, wrap `jest.runAllTimers()` in `act`: + +```js +import { act } from 'react-test-renderer'; + +// ... + +act(() => jest.runAllTimers()); +``` + +See the examples below for more details on how to use fake timers in tests involving navigation. -We recommend using [React Native Testing Library](https://callstack.github.io/react-native-testing-library/) along with [`jest-native`](https://github.com/testing-library/jest-native) to write your tests. +## Navigation and visibility + +In React Navigation, the previous screen is not unmounted when navigating to a new screen. This means that the previous screen is still present in the component tree, but it's not visible. + +When writing tests, you should assert that the expected component is visible or hidden instead of checking if it's rendered or not. React Native Testing Library provides a `toBeVisible` matcher that can be used to check if an element is visible to the user. + +```js +expect(screen.getByText('Settings screen')).toBeVisible(); +``` + +This is in contrast to the `toBeOnTheScreen` matcher, which checks if the element is rendered in the component tree. This matcher is not recommended when writing tests involving navigation. + +By default, the queries from React Native Testing Library (e.g. `getByRole`, `getByText`, `getByLabelText` etc.) [only return visible elements](https://callstack.github.io/react-native-testing-library/docs/api/queries#includehiddenelements-option). So you don't need to do anything special. However, if you're using a different library for your tests, you'll need to account for this behavior. + +## Example tests + +We recommend using [React Native Testing Library](https://callstack.github.io/react-native-testing-library/) to write your tests. + +In this guide, we will go through some example scenarios and show you how to write tests for them using Jest and React Native Testing Library: + +### Navigation between tabs + +In this example, we have a bottom tab navigator with two tabs: Home and Settings. We will write a test that asserts that we can navigate between these tabs by pressing the tab bar buttons. + + + + +```js title="MyTabs.js" +import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; +import { Text, View } from 'react-native'; + +const HomeScreen = () => { + return ( + + Home screen + + ); +}; + +const SettingsScreen = () => { + return ( + + Settings screen + + ); +}; + +export const MyTabs = createBottomTabNavigator({ + screens: { + Home: HomeScreen, + Settings: SettingsScreen, + }, +}); +``` + + + + +```js title="MyTabs.js" +import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; +import { Text, View } from 'react-native'; + +const HomeScreen = () => { + return ( + + Home screen + + ); +}; + +const SettingsScreen = () => { + return ( + + Settings screen + + ); +}; + +const Tab = createBottomTabNavigator(); + +export const MyTabs = () => { + return ( + + + + + ); +}; +``` -Example: + + - + -```js name='Testing with jest' -import * as React from 'react'; -import { screen, render, fireEvent } from '@testing-library/react-native'; +```js title="MyTabs.test.js" +import { expect, jest, test } from '@jest/globals'; import { createStaticNavigation } from '@react-navigation/native'; -import { RootNavigator } from './RootNavigator'; +import { act, render, screen, userEvent } from '@testing-library/react-native'; + +import { MyTabs } from './MyTabs'; + +jest.useFakeTimers(); -const Navigation = createStaticNavigation(RootNavigator); +test('navigates to settings by tab bar button press', async () => { + const user = userEvent.setup(); + + const Navigation = createStaticNavigation(MyTabs); -test('shows profile screen when View Profile is pressed', () => { render(); - fireEvent.press(screen.getByText('View Profile')); + const button = screen.getByRole('button', { name: 'Settings, tab, 2 of 2' }); + + await user.press(button); - expect(screen.getByText('My Profile')).toBeOnTheScreen(); + act(() => jest.runAllTimers()); + + expect(screen.getByText('Settings screen')).toBeVisible(); }); ``` -```js name='Testing with jest' -import * as React from 'react'; -import { screen, render, fireEvent } from '@testing-library/react-native'; +```js title="MyTabs.test.js" +import { expect, jest, test } from '@jest/globals'; import { NavigationContainer } from '@react-navigation/native'; -import { RootNavigator } from './RootNavigator'; +import { act, render, screen, userEvent } from '@testing-library/react-native'; + +import { MyTabs } from './MyTabs'; + +jest.useFakeTimers(); + +test('navigates to settings by tab bar button press', async () => { + const user = userEvent.setup(); -test('shows profile screen when View Profile is pressed', () => { render( - + ); - fireEvent.press(screen.getByText('View Profile')); + const button = screen.getByLabelText('Settings, tab, 2 of 2'); + + await user.press(button); + + act(() => jest.runAllTimers()); - expect(screen.getByText('My Profile')).toBeOnTheScreen(); + expect(screen.getByText('Settings screen')).toBeVisible(); }); ``` -## Best practices +In the above test, we: + +- Render the `MyTabs` navigator within a [NavigationContainer](navigation-container.md) in our test. +- Get the tab bar button using the `getByLabelText` query that matches its accessibility label. +- Press the button using `userEvent.press(button)` to simulate a user interaction. +- Run all timers using `jest.runAllTimers()` to skip animations (e.g. animations in the `Pressable` for the button). +- Assert that the `Settings screen` is visible after the navigation. + +### Reacting to a navigation event + +In this example, we have a stack navigator with two screens: Home and Surprise. We will write a test that asserts that the text "Surprise!" is displayed after navigating to the Surprise screen. + + + + +```js title="MyStack.js" +import { useNavigation } from '@react-navigation/native'; +import { createStackNavigator } from '@react-navigation/stack'; +import { Button, Text, View } from 'react-native'; +import { useEffect, useState } from 'react'; + +const HomeScreen = () => { + const navigation = useNavigation(); + + return ( + + Home screen +