Skip to main content
Version: 7.x

编写测试

React Navigation 组件可以以与其他 React 组件类似的方式进行测试。本指南将介绍如何使用 Jest 为使用 React Navigation 的组件编写测试。

¥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.

指导原则

¥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.

  • 避免模拟 React Navigation:模拟 React 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

为了能够测试 React Navigation 组件,需要根据所使用的组件来模拟某些依赖。

¥To be able to test React Navigation components, certain dependencies will need to be mocked depending on which components are being used.

如果你使用 @react-navigation/stack,则需要模拟:

¥If you're using @react-navigation/stack, you will need to mock:

  • react-native-gesture-handler

如果你使用 @react-navigation/drawer,则需要模拟:

¥If you're using @react-navigation/drawer, you will need to mock:

  • react-native-reanimated

  • react-native-gesture-handler

要添加模拟,请创建一个文件 jest/setup.js(或你选择的任何其他文件名)并将以下代码粘贴到其中:

¥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:

// Include this line for mocking react-native-gesture-handler
import 'react-native-gesture-handler/jestSetup';

// Include this section for mocking react-native-reanimated
import { setUpTests } from 'react-native-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');

然后我们需要在我们的 jest 配置中使用这个设置文件。你可以将其添加到 jest.config.js 文件中的 setupFilesAfterEnv 选项下或 package.json 中的 jest 键下:

¥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:

{
"preset": "react-native",
"setupFilesAfterEnv": ["<rootDir>/jest/setup.js"]
}

确保 setupFilesAfterEnv 中文件的路径正确。Jest 将在运行测试之前运行这些文件,因此它是放置全局模拟的最佳位置。

¥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

在大多数情况下,这不是必需的。但是,如果你出于某种原因需要模拟 react-native-screens 组件,则应通过在 jest/setup.js 文件中添加以下代码来实现:

¥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:

// 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;
});

如果你不使用 Jest,那么你需要根据你正在使用的测试框架来模拟这些模块。

¥If you're not using Jest, then you'll need to mock these modules according to the test framework you are using.

假计时器

¥Fake timers

当编写包含带动画的导航的测试时,你需要等到动画完成。在这种情况下,我们建议使用 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 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:

jest.useFakeTimers();

假计时器用使用假时钟的自定义实现替换原生计时器功能(例如 setTimeout()setInterval() 等)的实际实现。这让你可以立即跳过动画,并通过调用 jest.runAllTimers() 等方法减少运行测试所需的时间。

¥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().

通常,组件状态在动画完成后更新。为了避免在这种情况下出现错误,请将 jest.runAllTimers() 封装在 act 中:

¥Often, component state is updated after an animation completes. To avoid getting an error in such cases, wrap jest.runAllTimers() in act:

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.

¥Navigation and visibility

在 React Navigation 中,导航到新屏幕时不会卸载上一个屏幕。这意味着上一个屏幕仍然存在于组件树中,但不可见。

¥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.

在编写测试时,你应该断言预期的组件是可见的还是隐藏的,而不是检查它是否已渲染。React Native 测试库提供了一个 toBeVisible 匹配器,可用于检查元素是否对用户可见。

¥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.

expect(screen.getByText('Settings screen')).toBeVisible();

这与 toBeOnTheScreen 匹配器相反,后者检查元素是否在组件树中渲染。在编写涉及导航的测试时,不建议使用此匹配器。

¥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.

默认情况下,来自 React Native 测试库的查询(例如 getByRolegetByTextgetByLabelText 等)仅返回可见元素。因此,你不需要做任何特别的事情。但是,如果你在测试中使用了不同的库,则需要考虑此行为。

¥By default, the queries from React Native Testing Library (e.g. getByRole, getByText, getByLabelText etc.) only return visible elements. 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

我们建议使用 React Native 测试库 编写测试。

¥We recommend using React Native Testing Library to write your tests.

在本指南中,我们将介绍一些示例场景,并向你展示如何使用 Jest 和 React Native 测试库为它们编写测试:

¥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

在此示例中,我们有一个带有两个选项卡的底部选项卡导航器:Home 和 Settings。我们将编写一个测试,断言我们可以通过按下标签栏按钮在这些选项卡之间导航。

¥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.

MyTabs.js
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { Text, View } from 'react-native';

const HomeScreen = () => {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Home screen</Text>
</View>
);
};

const SettingsScreen = () => {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Settings screen</Text>
</View>
);
};

export const MyTabs = createBottomTabNavigator({
screens: {
Home: HomeScreen,
Settings: SettingsScreen,
},
});
MyTabs.test.js
import { expect, jest, test } from '@jest/globals';
import { createStaticNavigation } from '@react-navigation/native';
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();

const Navigation = createStaticNavigation(MyTabs);

render(<Navigation />);

const button = screen.getByRole('button', { name: 'Settings, tab, 2 of 2' });

await user.press(button);

act(() => jest.runAllTimers());

expect(screen.getByText('Settings screen')).toBeVisible();
});

在上面的测试中,我们:

¥In the above test, we:

  • 在我们的测试中,在 NavigationContainer 中渲染 MyTabs 导航器。

    ¥Render the MyTabs navigator within a NavigationContainer in our test.

  • 使用与可访问性标签匹配的 getByLabelText 查询获取标签栏按钮。

    ¥Get the tab bar button using the getByLabelText query that matches its accessibility label.

  • 使用 userEvent.press(button) 按下按钮以模拟用户交互。

    ¥Press the button using userEvent.press(button) to simulate a user interaction.

  • 使用 jest.runAllTimers() 运行所有计时器以跳过动画(例如,Pressable 中按钮的动画)。

    ¥Run all timers using jest.runAllTimers() to skip animations (e.g. animations in the Pressable for the button).

  • 断言导航后 Settings screen 可见。

    ¥Assert that the Settings screen is visible after the navigation.

对导航事件做出反应

¥Reacting to a navigation event

在此示例中,我们有一个带有两个屏幕的堆栈导航器:Home 和 Surprise。我们将编写一个测试,断言在导航到 Surprise 屏幕后会显示文本 "惊喜!"。

¥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.

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 (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Home screen</Text>
<Button
onPress={() => navigation.navigate('Surprise')}
title="Click here!"
/>
</View>
);
};

const SurpriseScreen = () => {
const navigation = useNavigation();

const [textVisible, setTextVisible] = useState(false);

useEffect(() => {
navigation.addListener('transitionEnd', () => setTextVisible(true));
}, [navigation]);

return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
{textVisible ? <Text>Surprise!</Text> : ''}
</View>
);
};

export const MyStack = createStackNavigator({
screens: {
Home: HomeScreen,
Surprise: SurpriseScreen,
},
});
MyStack.test.js
import { expect, jest, test } from '@jest/globals';
import { createStaticNavigation } from '@react-navigation/native';
import { act, render, screen, userEvent } from '@testing-library/react-native';

import { MyStack } from './MyStack';

jest.useFakeTimers();

test('shows surprise text after navigating to surprise screen', async () => {
const user = userEvent.setup();

const Navigation = createStaticNavigation(MyStack);

render(<Navigation />);

await user.press(screen.getByLabelText('Click here!'));

act(() => jest.runAllTimers());

expect(screen.getByText('Surprise!')).toBeVisible();
});

在上面的测试中,我们:

¥In the above test, we:

  • 在我们的测试中,在 NavigationContainer 中渲染 MyStack 导航器。

    ¥Render the MyStack navigator within a NavigationContainer in our test.

  • 使用与标题匹配的 getByLabelText 查询获取按钮。

    ¥Get the button using the getByLabelText query that matches its title.

  • 使用 userEvent.press(button) 按下按钮以模拟用户交互。

    ¥Press the button using userEvent.press(button) to simulate a user interaction.

  • 使用 jest.runAllTimers() 运行所有计时器以跳过动画(例如,屏幕之间的导航动画)。

    ¥Run all timers using jest.runAllTimers() to skip animations (e.g. navigation animation between screens).

  • 断言在转换到惊喜屏幕后 Surprise! 文本可见。

    ¥Assert that the Surprise! text is visible after the transition to the Surprise screen is complete.

使用 useFocusEffect 获取数据

¥Fetching data with useFocusEffect

在此示例中,我们有一个带有两个选项卡的底部选项卡导航器:Home 和 Pokemon。我们将编写一个测试,断言在 Pokemon 屏幕中聚焦时的数据获取逻辑。

¥In this example, we have a bottom tab navigator with two tabs: Home and Pokemon. We will write a test that asserts the data fetching logic on focus in the Pokemon screen.

MyTabs.js
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { useFocusEffect } from '@react-navigation/native';
import { useCallback, useState } from 'react';
import { Text, View } from 'react-native';

function HomeScreen() {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Home screen</Text>
</View>
);
}

const url = 'https://pokeapi.co/api/v2/pokemon/ditto';

function PokemonScreen() {
const [profileData, setProfileData] = useState({ status: 'loading' });

useFocusEffect(
useCallback(() => {
if (profileData.status === 'success') {
return;
}

setProfileData({ status: 'loading' });

const controller = new AbortController();

const fetchUser = async () => {
try {
const response = await fetch(url, { signal: controller.signal });
const data = await response.json();

setProfileData({ status: 'success', data: data });
} catch (error) {
setProfileData({ status: 'error' });
}
};

fetchUser();

return () => {
controller.abort();
};
}, [profileData.status])
);

if (profileData.status === 'loading') {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Loading...</Text>
</View>
);
}

if (profileData.status === 'error') {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>An error occurred!</Text>
</View>
);
}

return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>{profileData.data.name}</Text>
</View>
);
}

export const MyTabs = createBottomTabNavigator({
screens: {
Home: HomeScreen,
Pokemon: PokemonScreen,
},
});

为了使测试具有确定性并将其与真实后端隔离,你可以使用 模拟服务工作者 等库模拟网络请求:

¥To make the test deterministic and isolate it from the real backend, you can mock the network requests with a library such as Mock Service Worker:

msw-handlers.js
import { delay, http, HttpResponse } from 'msw';

export const handlers = [
http.get('https://pokeapi.co/api/v2/pokemon/ditto', async () => {
await delay(1000);

return HttpResponse.json({
id: 132,
name: 'ditto',
});
}),
];

在这里,我们设置了一个模拟 API 响应的处理程序(在此示例中,我们使用 PokéAPI)。此外,我们将响应延迟 1000 毫秒以模拟网络请求延迟。

¥Here we setup a handler that mocks responses from the API (for this example we're using PokéAPI). Additionally, we delay the response by 1000ms to simulate a network request delay.

然后,我们编写一个 Node.js 集成模块以在我们的测试中使用 Mock Service Worker:

¥Then, we write a Node.js integration module to use the Mock Service Worker in our tests:

msw-node.js
import { setupServer } from 'msw/node';
import { handlers } from './msw-handlers';

const server = setupServer(...handlers);

请参阅库的文档以了解有关在项目中进行设置的更多信息 - 入门, React Native 集成.

¥Refer to the documentation of the library to learn more about setting it up in your project - Getting started, React Native integration.

MyTabs.test.js
import './msw-node';

import { expect, jest, test } from '@jest/globals';
import { createStaticNavigation } from '@react-navigation/native';
import { act, render, screen, userEvent } from '@testing-library/react-native';

import { MyTabs } from './MyTabs';

jest.useFakeTimers();

test('loads data on Pokemon info screen after focus', async () => {
const user = userEvent.setup();

const Navigation = createStaticNavigation(MyTabs);

render(<Navigation />);

const homeTabButton = screen.getByLabelText('Home, tab, 1 of 2');
const profileTabButton = screen.getByLabelText('Profile, tab, 2 of 2');

await user.press(profileTabButton);

expect(screen.getByText('Loading...')).toBeVisible();

await act(() => jest.runAllTimers());

expect(screen.getByText('ditto')).toBeVisible();

await user.press(homeTabButton);

await act(() => jest.runAllTimers());

await user.press(profileTabButton);

expect(screen.queryByText('Loading...')).not.toBeVisible();
expect(screen.getByText('ditto')).toBeVisible();
});

在上面的测试中,我们:

¥In the above test, we:

  • 断言在获取数据时 Loading... 文本可见。

    ¥Assert that the Loading... text is visible while the data is being fetched.

  • 使用 jest.runAllTimers() 运行所有计时器以跳过网络请求中的延迟。

    ¥Run all timers using jest.runAllTimers() to skip delays in the network request.

  • 断言在获取数据后 ditto 文本可见。

    ¥Assert that the ditto text is visible after the data is fetched.

  • 按主页选项卡按钮导航到主屏幕。

    ¥Press the home tab button to navigate to the home screen.

  • 使用 jest.runAllTimers() 运行所有计时器以跳过动画(例如,Pressable 中按钮的动画)。

    ¥Run all timers using jest.runAllTimers() to skip animations (e.g. animations in the Pressable for the button).

  • 按个人资料选项卡按钮导航回 Pokemon 屏幕。

    ¥Press the profile tab button to navigate back to the Pokemon screen.

  • 通过断言 Loading... 文本不可见且 ditto 文本可见来确保显示缓存数据。

    ¥Ensure that cached data is shown by asserting that the Loading... text is not visible and the ditto text is visible.

注意

在生产应用中,我们建议使用像 React 查询 这样的库来处理数据获取和缓存。以上示例仅用于演示目的。

¥In a production app, we recommend using a library like React Query to handle data fetching and caching. The above example is for demonstration purposes only.