第 20 章

测试策略:Vitest + React Testing Library 完全指南

第20章:测试策略:Vitest + React Testing Library 完全指南

测试验证的是实现细节还是用户行为?答案决定了测试是资产还是负债。

本章核心问题:测试哲学'测行为不测实现'在实践中意味着什么? 读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

环境搭建

安装依赖

npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom

vitest.config.ts 配置

import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',       // 模拟浏览器 DOM 环境
    globals: true,              // 不需要每次 import describe/it/expect
    setupFiles: ['./src/test/setup.ts'],
  },
});

测试全局设置文件

// src/test/setup.ts
import '@testing-library/jest-dom'; // 扩展 expect 断言:toBeInTheDocument 等

@testing-library/jest-dom 提供了一批语义化的 DOM 断言,让测试代码更可读:

expect(element).toBeInTheDocument();
expect(element).toBeVisible();
expect(element).toBeDisabled();
expect(element).toHaveTextContent('确认');
expect(input).toHaveValue('[email protected]');

render + screen + userEvent 三件套

这是 React Testing Library 的基本使用模式:

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

test('点击按钮触发 onClick 回调', async () => {
  // 1. setup:userEvent.setup() 返回一个模拟用户的实例
  const user = userEvent.setup();
  const handleClick = vi.fn();

  // 2. render:渲染组件到虚拟 DOM
  render(<Button onClick={handleClick}>提交</Button>);

  // 3. screen:通过用户可感知的方式查找元素
  const button = screen.getByRole('button', { name: '提交' });

  // 4. 模拟用户操作
  await user.click(button);

  // 5. 断言结果
  expect(handleClick).toHaveBeenCalledOnce();
});

screen 的查询方法层级

screen 的查询方法按优先级排序如下,应当优先使用排名靠前的方法:

方法 适用场景
getByRole 最优先。按 ARIA 角色查询,最接近辅助技术的视角
getByLabelText 表单元素,通过 label 关联查找
getByPlaceholderText 无 label 的输入框(不推荐,但现实中常见)
getByText 按文本内容查找非交互元素
getByDisplayValue 表单元素的当前值
getByAltText 图片的 alt 属性
getByTestId 最后手段,用 data-testid 属性,仅当其他方式无法定位时使用

getBy* 找不到时抛出错误(快速失败);queryBy* 找不到时返回 null(用于断言元素不存在);findBy* 返回 Promise,用于异步出现的元素。

异步测试与 waitFor

真实组件往往涉及异步操作——数据加载、debounce 搜索、动画。

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

test('加载用户信息后显示姓名', async () => {
  const user = userEvent.setup();

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

  // 初始状态:显示加载中
  expect(screen.getByText('加载中...')).toBeInTheDocument();

  // 等待异步内容出现
  // findBy* 内部使用 waitFor,自动轮询直到元素出现或超时
  const nameElement = await screen.findByText('张三');
  expect(nameElement).toBeInTheDocument();

  // 不再显示加载状态
  expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});

// waitFor 用于等待任意断言变为真
test('搜索结果延迟出现', async () => {
  const user = userEvent.setup();
  render(<SearchBox />);

  await user.type(screen.getByRole('searchbox'), 'react');

  await waitFor(() => {
    expect(screen.getAllByRole('listitem')).toHaveLength(5);
  }, { timeout: 3000 }); // 默认超时 1000ms
});

重要:不要使用 act 手动包裹——userEventfindBy*/waitFor 已经在内部正确处理了 React 的批量更新,手动使用 act 反而容易出错。

Mock 策略

vi.mock 模拟模块

// 模拟整个模块
vi.mock('../services/api', () => ({
  fetchUser: vi.fn().mockResolvedValue({
    id: '1',
    name: '张三',
    email: '[email protected]',
  }),
}));

test('显示用户信息', async () => {
  render(<UserProfile userId="1" />);
  await screen.findByText('张三');
  expect(screen.getByText('[email protected]')).toBeInTheDocument();
});

MSW(Mock Service Worker)模拟 API

对于 HTTP 请求的 mock,MSW 是更健壮的方案。它在 Service Worker 层面拦截请求,让测试与实际的网络请求代码完全解耦:

// src/test/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({
      id: params.id,
      name: '张三',
      email: '[email protected]',
    });
  }),

  http.post('/api/users', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json({ id: 'new-id', ...body }, { status: 201 });
  }),
];

// src/test/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

// src/test/setup.ts(添加 MSW 的生命周期钩子)
import { server } from './server';

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers()); // 每个测试后重置,避免污染
afterAll(() => server.close());

在单个测试中临时覆盖 handler:

test('显示错误状态', async () => {
  server.use(
    http.get('/api/users/:id', () => {
      return new HttpResponse(null, { status: 500 });
    })
  );

  render(<UserProfile userId="1" />);
  await screen.findByText('加载失败,请重试');
});

MSW 的优势在于:你的组件代码不需要知道自己在被测试,fetch/axios 等请求照常发出,只是在网络层被拦截了。

测试自定义 Hook

renderHook 专门用于测试不依附于具体 UI 的 hook 逻辑:

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

test('计数器正常工作', () => {
  const { result } = renderHook(() => useCounter(0));

  expect(result.current.count).toBe(0);

  act(() => {
    result.current.increment();
  });

  expect(result.current.count).toBe(1);
});

test('不能低于最小值', () => {
  const { result } = renderHook(() => useCounter(0, { min: 0 }));

  act(() => {
    result.current.decrement();
  });

  expect(result.current.count).toBe(0); // 保持在 0,不变为 -1
});

如果 hook 依赖 Context,可以通过 renderHookwrapper 选项提供:

const { result } = renderHook(() => useAuth(), {
  wrapper: ({ children }) => (
    <AuthProvider initialUser={mockUser}>{children}</AuthProvider>
  ),
});

快照测试:何时有用,何时有害

快照测试(toMatchSnapshot)对于纯展示组件有一定价值,但很容易被滥用:

// 有用的场景:稳定的 UI 基准,如组件库
test('Button 渲染结果稳定', () => {
  const { container } = render(<Button variant="primary">提交</Button>);
  expect(container).toMatchSnapshot();
});

快照测试的问题在于:

建议:对于行为测试(点击、输入、状态变化),用 getBy* + 具体断言;对于纯展示组件的视觉稳定性,考虑 Storybook 的 visual regression test,而非快照测试。

覆盖率:测什么,不测什么

Vitest 内置覆盖率报告支持:

// vitest.config.ts
test: {
  coverage: {
    provider: 'v8',           // 或 'istanbul'
    reporter: ['text', 'html', 'lcov'],
    include: ['src/**/*.{ts,tsx}'],
    exclude: ['src/**/*.stories.tsx', 'src/test/**'],
    thresholds: {
      statements: 80,
      branches: 75,
      functions: 80,
      lines: 80,
    },
  },
}

覆盖率数字是指标,不是目标。80% 覆盖率配合精心设计的测试,远好于 100% 覆盖率配合大量无意义的快照和实现测试。

真正需要测试的场景:

可以跳过的场景:

CI 集成:GitHub Actions 示例

# .github/workflows/test.yml
name: Test

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run type check
        run: npm run type-check

      - name: Run tests with coverage
        run: npm run test:coverage

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v4
        with:
          file: ./coverage/lcov.info
          fail_ci_if_error: true

package.json 中的对应脚本:

{
  "scripts": {
    "test": "vitest",
    "test:coverage": "vitest run --coverage",
    "test:ui": "vitest --ui",
    "type-check": "tsc --noEmit"
  }
}

Level 2 · 它是怎么运行的(3-5年经验)

测试哲学:测行为,不测实现

React Testing Library 的核心理念来自其作者 Kent C. Dodds 的一句话:

"The more your tests resemble the way your software is used, the more confidence they can give you." (你的测试越接近软件实际的使用方式,它们能给你带来的信心就越大。)

这句话的具体含义是:

这种哲学让测试真正反映业务价值,而不是代码结构。当你安全地重构组件内部实现时,测试应当继续通过——如果重构引发了大量测试失败,说明测试耦合了实现细节。


Level 3 · 规范怎么定义的(资深)

一个完整的集成测试案例

以登录表单为例,展示真实测试的全貌:

// LoginForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
import { server } from '../test/server';
import { http, HttpResponse } from 'msw';

describe('LoginForm', () => {
  test('提交有效凭据后跳转到首页', async () => {
    const user = userEvent.setup();
    const onSuccess = vi.fn();

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

    // 填写表单
    await user.type(screen.getByLabelText('邮箱'), '[email protected]');
    await user.type(screen.getByLabelText('密码'), 'password123');
    await user.click(screen.getByRole('button', { name: '登录' }));

    // 等待异步操作完成
    await waitFor(() => {
      expect(onSuccess).toHaveBeenCalledWith(
        expect.objectContaining({ email: '[email protected]' })
      );
    });
  });

  test('密码错误时显示错误信息', async () => {
    server.use(
      http.post('/api/auth/login', () => {
        return HttpResponse.json(
          { message: '邮箱或密码错误' },
          { status: 401 }
        );
      })
    );

    const user = userEvent.setup();
    render(<LoginForm onSuccess={vi.fn()} />);

    await user.type(screen.getByLabelText('邮箱'), '[email protected]');
    await user.type(screen.getByLabelText('密码'), 'wrongpassword');
    await user.click(screen.getByRole('button', { name: '登录' }));

    await screen.findByText('邮箱或密码错误');
    expect(screen.getByRole('button', { name: '登录' })).not.toBeDisabled();
  });

  test('提交中按钮禁用防止重复提交', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSuccess={vi.fn()} />);

    await user.type(screen.getByLabelText('邮箱'), '[email protected]');
    await user.type(screen.getByLabelText('密码'), 'password123');
    await user.click(screen.getByRole('button', { name: '登录' }));

    // 提交中按钮应当禁用
    expect(screen.getByRole('button', { name: /登录中/ })).toBeDisabled();
  });
});

好的测试是代码质量文化的一部分,而不是流程的附加品。当团队真正理解"测行为不测实现"时,测试套件会变成一种让人有安全感的资产,而不是阻碍重构的包袱。


Level 4 · 边界与陷阱(所有人)

生产环境常见问题

在实际项目中,本章涵盖的概念最常见的问题包括:

  1. 忽视边界条件:在正常路径下工作正常的代码,在异常路径(网络失败、用户快速操作、组件卸载)下可能产生 bug。始终考虑清理和取消逻辑。

  2. 过早优化:在没有测量的情况下添加优化代码,增加了复杂度但不一定提升性能。先用 Profiler 确认问题存在,再实施优化。

  3. 文档与实际行为的差异:React 的行为在不同版本间可能有微妙差异,尤其是并发模式相关的特性。始终以实际测试结果为准。

本章评分
4.8  / 5  (9 评分)

💬 留言讨论