React测试Skill react-testing

React测试技能涵盖了使用Jest和React Testing Library进行组件渲染、用户交互和异步状态管理的测试模式。

前端开发 0 次安装 0 次浏览 更新于 3/3/2026

name: react-testing description: 针对React的Jest和React Testing Library测试模式。在编写测试、模拟模块、测试Zustand存储或调试React Web应用程序中的测试失败时使用。

React 测试(Web)

问题陈述

React测试需要理解组件渲染、用户交互和异步状态管理。这项技能涵盖了Jest与React Testing Library模式,用于Web应用程序。


模式:Zustand存储测试

**问题:**存储状态在测试之间持久存在,导致测试不稳定。

import { useAppStore } from '@/stores/appStore';

const initialState = {
  items: [],
  loading: false,
  error: null,
};

describe('App Store', () => {
  // 每个测试前重置存储
  beforeEach(() => {
    useAppStore.setState(initialState, true); // true = 替换整个状态
  });

  it('向存储中添加项目', async () => {
    const store = useAppStore.getState();

    await store.addItem({ id: '1', name: 'Test' });

    expect(useAppStore.getState().items).toHaveLength(1);
  });

  it('处理加载状态', async () => {
    const store = useAppStore.getState();

    const loadPromise = store.fetchItems();
    expect(useAppStore.getState().loading).toBe(true);

    await loadPromise;
    expect(useAppStore.getState().loading).toBe(false);
  });
});

关键点:

  • 使用setState(initialState, true)替换(不合并)状态
  • 在异步操作后使用getState()获取新状态
  • 在存储测试中不要依赖组件重新渲染

模式:异步存储操作

**问题:**测试异步Zustand操作并正确等待。

import { act, waitFor } from '@testing-library/react';

it('正确加载数据', async () => {
  const store = useAppStore.getState();

  // 将异步存储操作包装在act中
  await act(async () => {
    await store.loadData('123');
  });

  // 异步完成后验证状态
  await waitFor(() => {
    const state = useAppStore.getState();
    expect(Object.keys(state.data).length).toBeGreaterThan(0);
  });
});

// 对于复杂流程,验证每个步骤
it('完成多步骤流程', async () => {
  const store = useAppStore.getState();

  // 第1步
  await act(async () => {
    await store.loadItems();
  });
  expect(useAppStore.getState().items).toBeDefined();

  // 第2步
  await act(async () => {
    await store.processItems();
  });
  expect(useAppStore.getState().processed).toBe(true);
});

模式:组件测试

import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

describe('ItemCard', () => {
  const mockItem = {
    id: '1',
    title: 'Test Item',
    price: 99.99,
  };

  it('显示项目数据', () => {
    render(<ItemCard item={mockItem} />);

    expect(screen.getByText('Test Item')).toBeInTheDocument();
    expect(screen.getByText('$99.99')).toBeInTheDocument();
  });

  it('点击时调用onClick', async () => {
    const user = userEvent.setup();
    const onClick = jest.fn();

    render(<ItemCard item={mockItem} onClick={onClick} />);

    await user.click(screen.getByRole('button'));

    expect(onClick).toHaveBeenCalledWith(mockItem.id);
  });

  it('显示加载状态', () => {
    render(<ItemCard item={mockItem} loading />);

    expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
  });
});

模式:React Query测试

**问题:**使用React Query的组件需要QueryClientProvider。

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor } from '@testing-library/react';

function createTestQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
        gcTime: 0,
      },
    },
  });
}

function renderWithQuery(ui: React.ReactElement) {
  const queryClient = createTestQueryClient();
  return render(
    <QueryClientProvider client={queryClient}>
      {ui}
    </QueryClientProvider>
  );
}

// 测试中的使用
it('获取并显示数据', async () => {
  renderWithQuery(<UserProfile userId="123" />);

  // 最初显示加载
  expect(screen.getByText('Loading...')).toBeInTheDocument();

  // 等待数据
  await waitFor(() => {
    expect(screen.getByText('John Doe')).toBeInTheDocument();
  });
});

模式:自定义Hook测试

import { renderHook, act, waitFor } from '@testing-library/react';

describe('useAuth', () => {
  it('用户登录', async () => {
    const { result } = renderHook(() => useAuth(), {
      wrapper: AuthProvider, // 如果钩子需要上下文
    });

    await act(async () => {
      await result.current.signIn('test@example.com', 'password');
    });

    expect(result.current.user).toBeDefined();
    expect(result.current.isAuthenticated).toBe(true);
  });

  it('处理登录错误', async () => {
    const { result } = renderHook(() => useAuth(), {
      wrapper: AuthProvider,
    });

    await act(async () => {
      try {
        await result.current.signIn('invalid@example.com', 'wrong');
      } catch (e) {
        // 预期
      }
    });

    expect(result.current.error).toBe('Invalid credentials');
  });
});

// 带Zustand的Hook
describe('useUserData', () => {
  beforeEach(() => {
    useUserStore.setState(initialState, true);
  });

  it('返回当前用户数据', () => {
    // 预填充存储
    useUserStore.setState({ user: { id: '1', name: 'Test' } });

    const { result } = renderHook(() => useUserData());

    expect(result.current.user.name).toBe('Test');
  });
});

模式:模拟API调用

// 全局模拟fetch
global.fetch = jest.fn();

beforeEach(() => {
  (fetch as jest.Mock).mockClear();
});

it('获取用户数据', async () => {
  (fetch as jest.Mock).mockResolvedValueOnce({
    ok: true,
    json: async () => ({ id: '1', name: 'John' }),
  });

  render(<UserProfile userId="1" />);

  await waitFor(() => {
    expect(screen.getByText('John')).toBeInTheDocument();
  });

  expect(fetch).toHaveBeenCalledWith('/api/users/1');
});

// 模拟特定模块
jest.mock('@/api/users', () => ({
  getUser: jest.fn(),
  updateUser: jest.fn(),
}));

import { getUser, updateUser } from '@/api/users';

it('加载并更新用户', async () => {
  (getUser as jest.Mock).mockResolvedValue({ id: '1', name: 'John' });
  (updateUser as jest.Mock).mockResolvedValue({ id: '1', name: 'Jane' });

  // 测试使用这些的组件
});

模式:路由器测试

import { MemoryRouter, Routes, Route } from 'react-router-dom';

function renderWithRouter(ui: React.ReactElement, { route = '/' } = {}) {
  return render(
    <MemoryRouter initialEntries={[route]}>
      {ui}
    </MemoryRouter>
  );
}

// 测试导航
it('点击按钮后导航到个人资料', async () => {
  const user = userEvent.setup();

  renderWithRouter(
    <Routes>
      <Route path="/" element={<HomePage />} />
      <Route path="/profile" element={<ProfilePage />} />
    </Routes>
  );

  await user.click(screen.getByText('Go to Profile'));

  expect(screen.getByText('Profile Page')).toBeInTheDocument();
});

// 测试带路由参数
it('显示来自路由参数的用户', async () => {
  renderWithRouter(
    <Routes>
      <Route path="/users/:id" element={<UserPage />} />
    </Routes>,
    { route: '/users/123' }
  );

  await waitFor(() => {
    expect(screen.getByText('User 123')).toBeInTheDocument();
  });
});

模式:表单测试

import userEvent from '@testing-library/user-event';

describe('LoginForm', () => {
  it('提交表单与输入的数据', async () => {
    const user = userEvent.setup();
    const onSubmit = jest.fn();

    render(<LoginForm onSubmit={onSubmit} />);

    await user.type(screen.getByLabelText('Email'), 'test@example.com');
    await user.type(screen.getByLabelText('Password'), 'password123');
    await user.click(screen.getByRole('button', { name: 'Sign In' }));

    expect(onSubmit).toHaveBeenCalledWith({
      email: 'test@example.com',
      password: 'password123',
    });
  });

  it('显示验证错误', async () => {
    const user = userEvent.setup();

    render(<LoginForm onSubmit={jest.fn()} />);

    // 未填写表单即提交
    await user.click(screen.getByRole('button', { name: 'Sign In' }));

    expect(screen.getByText('Email is required')).toBeInTheDocument();
    expect(screen.getByText('Password is required')).toBeInTheDocument();
  });

  it('加载时禁用提交', async () => {
    render(<LoginForm onSubmit={jest.fn()} loading />);

    expect(screen.getByRole('button', { name: 'Sign In' })).toBeDisabled();
  });
});

模式:避免act()警告

问题: “警告:测试中的更新没有被包装在act(…)”

// 错误 - 状态更新发生在测试之后
it('加载数据', () => {
  render(<DataComponent />);
  // 组件异步获取数据,测试结束后更新状态
});

// 正确 - 等待异步完成
it('加载数据', async () => {
  render(<DataComponent />);

  // 等待加载完成
  await waitFor(() => {
    expect(screen.getByText('Data loaded')).toBeInTheDocument();
  });
});

// 正确 - 使用findBy*(内置waitFor)
it('加载数据', async () => {
  render(<DataComponent />);

  const element = await screen.findByText('Data loaded');
  expect(element).toBeInTheDocument();
});

模式:快照测试

何时使用:

  • 具有稳定结构的UI组件
  • 设计系统组件
  • 视觉回归重要的组件

何时避免:

  • 具有动态内容的组件
  • 频繁变化的组件
  • 大型组件树(脆弱)
// 好的快照候选 - 稳定的UI组件
it('正确渲染', () => {
  const { container } = render(<Button variant="primary">Submit</Button>);
  expect(container).toMatchSnapshot();
});

// 不好的快照候选 - 动态内容
it('渲染用户列表', () => {
  // 不要快照 - 列表内容变化
  // 相反,测试特定行为
});

模式:测试上下文提供者

// 创建一个包含所有提供者的包装器
function AllProviders({ children }: { children: React.ReactNode }) {
  const queryClient = createTestQueryClient();
  return (
    <QueryClientProvider client={queryClient}>
      <AuthProvider>
        <ThemeProvider>
          {children}
        </ThemeProvider>
      </AuthProvider>
    </QueryClientProvider>
  );
}

function renderWithProviders(ui: React.ReactElement) {
  return render(ui, { wrapper: AllProviders });
}

// 测试中的使用
it('用所有上下文渲染', () => {
  renderWithProviders(<Dashboard />);
  // 组件可以访问所有提供者
});

测试命令

npm test                      # 运行所有测试
npm test -- --watch           # 监视模式
npm test -- --coverage        # 覆盖率报告
npm test -- Button            # 运行特定测试文件
npm test -- --updateSnapshot  # 更新快照
npm test -- --runInBand       # 串行运行测试(调试)

常见问题

问题 解决方案
“无法找到模块” 检查jest moduleNameMapper配置
act()警告 用act()包装状态更新,使用waitFor/findBy
存储状态渗透 添加beforeEach重置setState
异步测试超时 增加超时或检查悬挂的承诺
模拟不工作 验证模拟路径与导入路径完全匹配
查询未找到 使用findBy*用于异步内容,检查可访问性

推荐文件结构

__tests__/
  utils/
    test-utils.tsx         # 自定义渲染与提供者
    query-test-utils.tsx   # QueryClient包装器
jest.setup.js              # 全局模拟和设置
jest.config.js             # Jest配置