跳到主要内容
版本:7.x

编写测试

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

指导原则

编写测试时,建议编写与用户和你的应用交互方式密切相关的测试。考虑到这一点,以下是一些需要遵循的指导原则

  • 测试结果,而不是动作:与其检查是否调用了特定的导航动作,不如检查导航后是否渲染了预期的组件。
  • 避免模拟 React Navigation:模拟 React Navigation 组件可能导致测试与实际逻辑不符。相反,在你的测试中使用真实的导航器。

遵循这些原则将帮助你编写更可靠且更易于维护的测试,从而避免测试实现细节。

模拟原生依赖项

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

如果你正在使用 @react-navigation/stack,你将需要模拟

  • react-native-gesture-handler

如果你正在使用 @react-navigation/drawer,你将需要模拟

  • react-native-reanimated
  • react-native-gesture-handler

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

// 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 配置中使用这个 setup 文件。你可以将其添加到 jest.config.js 文件中的 setupFilesAfterEnv 选项下,或者 package.json 中的 jest 键下

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

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

模拟 react-native-screens

在大多数情况下,这应该不是必要的。但是,如果你发现自己需要出于某种原因模拟 react-native-screens 组件,你应该通过在 jest/setup.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;
});

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

伪造定时器

当编写包含动画导航的测试时,你需要等待动画完成。在这种情况下,我们建议使用 Fake Timers 来模拟测试中时间的流逝。这可以通过在你的测试文件开头添加以下行来完成

jest.useFakeTimers();

伪造定时器用使用伪造时钟的自定义实现替换了原生定时器函数(例如 setTimeout()setInterval() 等)的真实实现。这使你可以通过调用诸如 jest.runAllTimers() 之类的方法立即跳过动画并减少运行测试所需的时间。

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

import { act } from 'react-test-renderer';

// ...

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

有关如何在涉及导航的测试中使用伪造定时器的更多详细信息,请参见以下示例。

在 React Navigation 中,当导航到新屏幕时,之前的屏幕不会被卸载。这意味着之前的屏幕仍然存在于组件树中,但它是不可见的。

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

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

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

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

示例测试

我们建议使用 React Native Testing Library 来编写你的测试。

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

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

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

在上面的测试中,我们

  • 在我们的测试中,在 NavigationContainer 中渲染 MyTabs 导航器。
  • 使用 getByLabelText 查询获取选项卡栏按钮,该查询匹配其可访问性标签。
  • 使用 userEvent.press(button) 按下按钮以模拟用户交互。
  • 使用 jest.runAllTimers() 运行所有定时器以跳过动画(例如按钮 Pressable 中的动画)。
  • 断言导航后 Settings screen 是可见的。

对导航事件做出反应

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

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

在上面的测试中,我们

  • 在我们的测试中,在 NavigationContainer 中渲染 MyStack 导航器。
  • 使用 getByLabelText 查询获取按钮,该查询匹配其标题。
  • 使用 userEvent.press(button) 按下按钮以模拟用户交互。
  • 使用 jest.runAllTimers() 运行所有定时器以跳过动画(例如屏幕之间的导航动画)。
  • 断言在 Surprise 屏幕的过渡完成后,Surprise! 文本是可见的。

获取数据 useFocusEffect

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

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

为了使测试具有确定性并将其与真实后端隔离,你可以使用诸如 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)。此外,我们将响应 delay 延迟 1000 毫秒以模拟网络请求延迟。

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

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

const server = setupServer(...handlers);

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

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

在上面的测试中,我们

  • 断言在数据被获取时,Loading... 文本是可见的。
  • 使用 jest.runAllTimers() 运行所有定时器以跳过网络请求中的延迟。
  • 断言在数据被获取后,ditto 文本是可见的。
  • 按下 home 选项卡按钮以导航到 home 屏幕。
  • 使用 jest.runAllTimers() 运行所有定时器以跳过动画(例如按钮 Pressable 中的动画)。
  • 按下 profile 选项卡按钮以导航回 Pokemon 屏幕。
  • 通过断言 Loading... 文本不可见且 ditto 文本可见,确保显示缓存的数据。
注意

在生产应用中,我们建议使用诸如 React Query 之类的库来处理数据获取和缓存。上面的示例仅用于演示目的。