name: rn-testing description: 测试React Native中的模式,使用Jest和React Native Testing Library。在编写测试、模拟Expo模块、测试Zustand存储或调试测试失败时使用。
React Native 测试
问题陈述
React Native测试需要广泛模拟原生模块,仔细处理异步操作,并理解Zustand存储测试模式。此代码库有30多个测试文件,建立了测试模式。
模式:Zustand存储测试
**问题:**存储状态在测试之间持久化,导致测试不稳定。
import { useAssessmentStore } from '@/stores/assessmentStore';
const initialState = {
userAnswers: {},
completedAssessmentAnswers: {},
retakeAreas: new Set<string>(),
loading: false,
};
describe('Assessment Store', () => {
// 每个测试前重置存储
beforeEach(() => {
useAssessmentStore.setState(initialState, true); // true = 替换整个状态
});
it('saves answer to store', async () => {
const store = useAssessmentStore.getState();
await store.saveAnswer('q1', 4);
expect(useAssessmentStore.getState().userAnswers['q1']).toBe(4);
});
it('enables retake for skill area', async () => {
const store = useAssessmentStore.getState();
await store.enableSkillAreaRetake('fundamentals');
expect(useAssessmentStore.getState().retakeAreas.has('fundamentals')).toBe(true);
});
});
关键点:
- 使用
setState(initialState, true)替换(不合并)状态 - 异步操作后使用
getState()获取新状态 - 在存储测试中不依赖组件重新渲染
模式:异步存储操作
**问题:**测试异步Zustand操作并正确等待。
import { act, waitFor } from '@testing-library/react-native';
it('loads completed answers', async () => {
const store = useAssessmentStore.getState();
// 将异步存储操作包装在act中
await act(async () => {
await store.loadCompletedAssessmentAnswers('assessment-123');
});
// 异步完成后验证状态
await waitFor(() => {
const state = useAssessmentStore.getState();
expect(Object.keys(state.completedAssessmentAnswers).length).toBeGreaterThan(0);
});
});
// 对于复杂流程,验证每个步骤
it('completes retake flow', async () => {
const store = useAssessmentStore.getState();
// 第1步
await act(async () => {
await store.loadCompletedAssessmentAnswers('assessment-123');
});
expect(useAssessmentStore.getState().completedAssessmentAnswers).toBeDefined();
// 第2步
await act(async () => {
await store.enableSkillAreaRetake('fundamentals');
});
expect(useAssessmentStore.getState().retakeAreas.has('fundamentals')).toBe(true);
// 第3步
await act(async () => {
await store.saveAnswer('q1', 4);
});
expect(useAssessmentStore.getState().userAnswers['q1']).toBe(4);
});
模式:Expo模块模拟
**问题:**Expo模块需要Jest模拟。
// __mocks__/expo-router.ts (或在jest.setup.js中)
jest.mock('expo-router', () => ({
useRouter: () => ({
push: jest.fn(),
replace: jest.fn(),
back: jest.fn(),
dismiss: jest.fn(),
}),
useLocalSearchParams: () => ({}),
useSegments: () => [],
usePathname: () => '/',
Link: ({ children }: { children: React.ReactNode }) => children,
Stack: {
Screen: () => null,
},
}));
// expo-secure-store
jest.mock('expo-secure-store', () => ({
getItemAsync: jest.fn(),
setItemAsync: jest.fn(),
deleteItemAsync: jest.fn(),
}));
// expo-constants
jest.mock('expo-constants', () => ({
expoConfig: {
extra: {
apiUrl: 'http://test-api.local',
},
},
}));
// expo-haptics
jest.mock('expo-haptics', () => ({
impactAsync: jest.fn(),
notificationAsync: jest.fn(),
selectionAsync: jest.fn(),
}));
检查jest.setup.js - 许多模拟已经全局配置。
模式:React Query测试
**问题:**使用React Query的组件需要QueryClientProvider。
// 使用代码库中的现有实用程序
import { createTestQueryClient, QueryWrapper } from '@/__tests__/utils/react-query-test-utils';
// 或创建包装器
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
},
});
}
function QueryWrapper({ children }: { children: React.ReactNode }) {
const queryClient = createTestQueryClient();
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
// 测试中的使用
import { render, waitFor } from '@testing-library/react-native';
it('fetches and displays data', async () => {
const { getByText } = render(
<QueryWrapper>
<MyComponent />
</QueryWrapper>
);
await waitFor(() => {
expect(getByText('Loaded data')).toBeTruthy();
});
});
模式:自定义Hook测试
import { renderHook, act, waitFor } from '@testing-library/react-native';
describe('useAuth', () => {
it('signs in user', async () => {
const { result } = renderHook(() => useAuth(), {
wrapper: AuthProvider, // 如果钩子需要上下文
});
await act(async () => {
await result.current.signIn('token', mockUser);
});
expect(result.current.user).toEqual(mockUser);
expect(result.current.token).toBe('token');
});
});
// 带Zustand的Hook
describe('useAssessmentAnswers', () => {
beforeEach(() => {
useAssessmentStore.setState(initialState, true);
});
it('returns current answers', () => {
// 预填充存储
useAssessmentStore.setState({ userAnswers: { q1: 4 } });
const { result } = renderHook(() => useAssessmentAnswers());
expect(result.current.answers).toEqual({ q1: 4 });
});
});
模式:组件测试
import { render, fireEvent, waitFor } from '@testing-library/react-native';
describe('SessionCard', () => {
const mockSession = {
id: '1',
title: 'Serve Practice',
totalDuration: 45, // 后端计算
};
it('displays session data from backend', () => {
const { getByText } = render(<SessionCard session={mockSession} />);
expect(getByText('Serve Practice')).toBeTruthy();
expect(getByText('45 min')).toBeTruthy();
});
it('calls onPress when tapped', () => {
const onPress = jest.fn();
const { getByTestId } = render(
<SessionCard session={mockSession} onPress={onPress} />
);
fireEvent.press(getByTestId('session-card'));
expect(onPress).toHaveBeenCalledWith(mockSession.id);
});
});
模式:导航测试
import { useRouter } from 'expo-router';
jest.mock('expo-router');
describe('SettingsScreen', () => {
const mockPush = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
(useRouter as jest.Mock).mockReturnValue({
push: mockPush,
back: jest.fn(),
});
});
it('navigates to profile on button press', () => {
const { getByText } = render(<SettingsScreen />);
fireEvent.press(getByText('Edit Profile'));
expect(mockPush).toHaveBeenCalledWith('/profile/edit');
});
});
// 使用路由参数测试
jest.mock('expo-router', () => ({
useLocalSearchParams: () => ({ id: 'test-assessment-123' }),
useRouter: () => ({ push: jest.fn() }),
}));
模式:避免act()警告
问题:“警告:测试中的更新没有被包装在act(…)”
// 错误 - 状态更新发生在测试后
it('loads data', () => {
render(<DataComponent />);
// 组件异步获取数据,测试结束后更新状态
});
// 正确 - 等待异步完成
it('loads data', async () => {
const { getByText } = render(<DataComponent />);
// 等待加载完成
await waitFor(() => {
expect(getByText('Data loaded')).toBeTruthy();
});
});
// 正确 - 使用findBy*(内置waitFor)
it('loads data', async () => {
const { findByText } = render(<DataComponent />);
const element = await findByText('Data loaded');
expect(element).toBeTruthy();
});
模式:快照测试
何时使用:
- 具有稳定结构的UI组件
- 设计系统组件
- 视觉回归重要的组件
何时避免:
- 具有动态内容的组件
- 经常变化的组件
- 大型组件树(脆弱)
// 好的快照候选 - 稳定的UI组件
it('renders correctly', () => {
const tree = render(<Button title="Submit" />);
expect(tree.toJSON()).toMatchSnapshot();
});
// 坏的快照候选 - 动态内容
it('renders user list', () => {
// 不要快照 - 列表内容变化
// 相反,测试特定行为
});
模式:模拟API调用
// 模拟Orval生成的钩子
jest.mock('@/api/generated/assessments', () => ({
useGetAssessment: jest.fn(() => ({
data: mockAssessment,
isLoading: false,
error: null,
})),
useSubmitAssessment: jest.fn(() => ({
mutate: jest.fn(),
isLoading: false,
})),
}));
// 用不同状态模拟
import { useGetAssessment } from '@/api/generated/assessments';
it('shows loading state', () => {
(useGetAssessment as jest.Mock).mockReturnValue({
data: null,
isLoading: true,
error: null,
});
const { getByTestId } = render(<AssessmentScreen />);
expect(getByTestId('loading-spinner')).toBeTruthy();
});
it('shows error state', () => {
(useGetAssessment as jest.Mock).mockReturnValue({
data: null,
isLoading: false,
error: new Error('Failed to load'),
});
const { getByText } = render(<AssessmentScreen />);
expect(getByText('Failed to load')).toBeTruthy();
});
推荐测试工具
__tests__/utils/react-query-test-utils.tsx # QueryClient包装器
jest.setup.js # 全局模拟
测试命令
npm test # 运行所有测试
npm test -- --watch # 监视模式
npm test -- --coverage # 覆盖率报告
npm test -- SessionCard # 运行特定测试文件
npm test -- --updateSnapshot # 更新快照
常见问题
| 问题 | 解决方案 |
|---|---|
| “无法找到模块” | 检查jest.setup.js模块映射 |
| act()警告 | 将状态更新包装在act()中,使用waitFor/findBy |
| 存储状态渗透 | 添加beforeEach带有setState重置 |
| 异步测试超时 | 增加超时或检查悬挂的承诺 |
| 模拟不起作用 | 验证模拟路径是否与导入路径完全匹配 |
与其他技能的关系
- rn-async-patterns:在测试中使用后条件验证来验证异步流程
- rn-zustand-patterns:理解
getState()行为以进行存储测试 - rn-state-flows:集成测试应覆盖整个流程,而不仅仅是单位