编写测试
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 的查询(例如 getByRole
、getByText
、getByLabelText
等)仅返回可见元素。因此你不需要做任何特殊的事情。但是,如果你正在为你的测试使用不同的库,你将需要考虑此行为。
示例测试
我们建议使用 React Native Testing Library 来编写你的测试。
在本指南中,我们将介绍一些示例场景,并向你展示如何使用 Jest 和 React Native Testing Library 为它们编写测试
选项卡之间的导航
在此示例中,我们有一个带有两个选项卡的底部选项卡导航器:Home 和 Settings。我们将编写一个测试,断言我们可以通过按下选项卡栏按钮在这些选项卡之间导航。
- 静态
- 动态
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,
},
});
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>
);
};
const Tab = createBottomTabNavigator();
export const MyTabs = () => {
return (
<Tab.Navigator>
<Tab.Screen name="Home" component={HomeScreen} />
<Tab.Screen name="Settings" component={SettingsScreen} />
</Tab.Navigator>
);
};
- 静态
- 动态
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();
});
import { expect, jest, test } from '@jest/globals';
import { NavigationContainer } 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();
render(
<NavigationContainer>
<MyTabs />
</NavigationContainer>
);
const button = screen.getByLabelText('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!”。
- 静态
- 动态
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,
},
});
import { useNavigation } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import { useEffect, useState } from 'react';
import { Button, Text, View } from 'react-native';
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>
);
};
const Stack = createStackNavigator();
export const MyStack = () => {
return (
<Stack.Navigator>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="Surprise" component={SurpriseScreen} />
</Stack.Navigator>
);
};
- 静态
- 动态
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();
});
import { expect, jest, test } from '@jest/globals';
import { NavigationContainer } 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();
render(
<NavigationContainer>
<MyStack />
</NavigationContainer>
);
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 屏幕中焦点事件的数据获取逻辑。
- 静态
- 动态
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,
},
});
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 PokemonInfoScreen() {
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>
);
}
const Tab = createBottomTabNavigator();
export function MyTabs() {
return (
<Tab.Navigator screenOptions={{ headerShown: false }}>
<Tab.Screen name="Home" component={HomeScreen} />
<Tab.Screen name="Pokemon" component={PokemonScreen} />
</Tab.Navigator>
);
}
为了使测试具有确定性并将其与真实后端隔离,你可以使用诸如 Mock Service Worker 之类的库来模拟网络请求
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
import { setupServer } from 'msw/node';
import { handlers } from './msw-handlers';
const server = setupServer(...handlers);
请参阅库的文档以了解有关在你的项目中设置它的更多信息 - 入门,React Native 集成。
- 静态
- 动态
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();
});
import './msw-node';
import { expect, jest, test } from '@jest/globals';
import { NavigationContainer } 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();
render(
<NavigationContainer>
<MyTabs />
</NavigationContainer>
);
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 之类的库来处理数据获取和缓存。上面的示例仅用于演示目的。