ReactNative测试Skill rn-testing

本文档提供了React Native应用测试的多种模式和实践,包括Zustand存储测试、异步操作测试、Expo模块模拟、React Query测试、自定义Hook测试、组件测试、导航测试、避免act()警告、快照测试和API调用模拟等。

移动开发 0 次安装 0 次浏览 更新于 3/3/2026

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:集成测试应覆盖整个流程,而不仅仅是单位