Chapter 09

测试:Vitest 与 Testing Library

从单元测试到组件测试,建立重构信心,打造高质量 React 应用

1. 为什么写测试

🛡️
回归保护修改代码后,测试能立即告诉你是否破坏了原有功能,无需手动点击每个页面验证。
🔨
重构信心有测试覆盖的代码可以大胆重构内部实现,只要测试通过,外部行为就没变化。
📖
活文档好的测试用例描述了组件应该如何行为,是比注释更可靠的文档(测试会因代码变化而失败,注释不会)。

测试金字塔

测试分为三层,越靠底层数量越多、速度越快、成本越低:

层级测试对象工具数量速度
单元测试(Unit)纯函数、工具函数、HookVitest最多极快(毫秒)
集成测试(Integration)组件 + 依赖交互Testing Library中等较快(秒)
端到端测试(E2E)完整用户流程(真实浏览器)Playwright / Cypress最少慢(分钟)

2. 测试工具栈介绍

3. 安装与配置

# 安装测试依赖
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
// vite.config.ts — 添加 test 配置
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',            // 使用 jsdom 模拟浏览器环境
    globals: true,                   // 无需 import describe/it/expect
    setupFiles: ['./src/setupTests.ts'],  // 每个测试文件前执行
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html'],
    },
  },
});
// src/setupTests.ts — 全局配置
import '@testing-library/jest-dom';
// 导入后,所有测试文件都能使用 toBeInTheDocument() 等断言
// package.json — 添加测试脚本
{
  "scripts": {
    "test": "vitest",           // 监听模式
    "test:run": "vitest run",  // 运行一次(CI 用)
    "test:ui": "vitest --ui",  // 浏览器 UI 界面
    "coverage": "vitest run --coverage"
  }
}

4. 测试基础概念

// 基本结构:describe 分组 + it/test 描述用例
describe('formatPrice 函数', () => {
  // beforeEach 在每个测试前执行(初始化状态)
  beforeEach(() => { /* 重置状态 */ });
  afterEach(() => { /* 清理(如清除 mock)*/ });

  // it / test 是同一函数的别名
  it('应该格式化整数价格', () => {
    // AAA 模式:Arrange(准备) / Act(行动) / Assert(断言)
    const price = 1234;               // Arrange
    const result = formatPrice(price);  // Act
    expect(result).toBe('¥1,234.00'); // Assert
  });

  it('应该处理零值', () => {
    expect(formatPrice(0)).toBe('¥0.00');
  });

  it.todo('应该处理负数');  // 标记待实现的测试
});

5. 单元测试:纯函数

// utils/string.ts — 被测试的函数
export function truncate(text: string, maxLength: number): string {
  if (text.length <= maxLength) return text;
  return text.slice(0, maxLength) + '...';
}

export function slugify(text: string): string {
  return text
    .toLowerCase()
    .replace(/[^\w\s-]/g, '')   // 移除特殊字符
    .replace(/\s+/g, '-');        // 空格换连字符
}
// utils/string.test.ts — 对应测试文件
import { truncate, slugify } from './string';

describe('truncate', () => {
  it('文本短于限制时原样返回', () => {
    expect(truncate('hello', 10)).toBe('hello');
  });

  it('文本超出限制时截断并加省略号', () => {
    expect(truncate('hello world', 5)).toBe('hello...');
  });

  it('等于限制长度时不截断', () => {
    expect(truncate('hello', 5)).toBe('hello');
  });
});

describe('slugify', () => {
  it('转换为小写并用连字符连接', () => {
    expect(slugify('Hello World')).toBe('hello-world');
  });
});

6. 组件测试:Testing Library

Testing Library 的核心原则:不测试实现细节(如组件的 state、内部方法),而是测试用户看到和交互的内容。

查询优先级

按推荐程度从高到低,优先使用用户能感知的查询方式:

  1. getByRole — 通过 ARIA 角色查找(按钮、链接、标题等)首选
  2. getByLabelText — 通过表单 label 文字查找
  3. getByPlaceholderText — 通过 placeholder 查找
  4. getByText — 通过可见文字查找
  5. getByDisplayValue — 通过 input 当前值查找
  6. getByAltText — 通过图片 alt 查找
  7. getByTestId — 通过 data-testid 属性查找 最后选择

getBy vs queryBy vs findBy

前缀找不到时找到多个时适合场景
getBy抛出错误抛出错误断言元素存在
queryBy返回 null抛出错误断言元素不存在
findBy抛出错误(异步)抛出错误等待异步元素出现
getAllBy抛出错误返回数组查找多个元素
// components/Counter.test.tsx — 完整组件测试示例
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Counter } from './Counter';

describe('Counter 组件', () => {
  it('初始值为 0', () => {
    render(<Counter />);
    // getByRole('heading') 匹配 h1-h6 标签
    expect(screen.getByRole('heading', { name: '计数: 0' })).toBeInTheDocument();
  });

  it('点击增加按钮后计数加一', async () => {
    // userEvent 需要调用 setup() 才能正确处理事件序列
    const user = userEvent.setup();
    render(<Counter />);

    // 通过 ARIA 角色和名称查找按钮
    await user.click(screen.getByRole('button', { name: '增加' }));

    expect(screen.getByRole('heading', { name: '计数: 1' })).toBeInTheDocument();
  });

  it('在输入框输入初始值', async () => {
    const user = userEvent.setup();
    render(<Counter />);

    const input = screen.getByLabelText('初始值');
    await user.clear(input);
    await user.type(input, '5');

    expect(input).toHaveValue(5);
  });
});

测试异步操作

// 测试点击后异步加载数据
it('点击搜索后显示结果', async () => {
  const user = userEvent.setup();
  render(<SearchBox />);

  await user.type(screen.getByRole('searchbox'), 'React');
  await user.click(screen.getByRole('button', { name: '搜索' }));

  // findBy 会等待元素出现(默认超时 1000ms)
  const results = await screen.findByRole('list');
  expect(results).toBeInTheDocument();

  // 或者用 waitFor 等待断言通过
  await waitFor(() => {
    expect(screen.getAllByRole('listitem').length).toBeGreaterThan(0);
  });
});

7. Mock(模拟)

vi.fn() — 函数 Mock

it('提交表单时调用 onSubmit 回调', async () => {
  const user = userEvent.setup();
  const onSubmit = vi.fn();  // 创建一个监视函数(spy)

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

  await user.type(screen.getByLabelText('邮箱'), 'test@example.com');
  await user.type(screen.getByLabelText('密码'), 'password123');
  await user.click(screen.getByRole('button', { name: '登录' }));

  // 断言函数被调用,且调用参数正确
  expect(onSubmit).toHaveBeenCalledTimes(1);
  expect(onSubmit).toHaveBeenCalledWith({
    email: 'test@example.com',
    password: 'password123',
  });
});

vi.mock() — 模块 Mock

// 模拟整个模块(如 axios)
vi.mock('axios');
import axios from 'axios';
import { vi } from 'vitest';

it('获取用户列表', async () => {
  // 设置 mock 返回值
  vi.mocked(axios.get).mockResolvedValue({
    data: [{ id: 1, name: 'Alice' }],
  });

  const user = userEvent.setup();
  render(<UserList />);

  await screen.findByText('Alice');
  expect(screen.getByText('Alice')).toBeInTheDocument();
});

MSW — Mock Service Worker(推荐方案)

直接 mock axios/fetch 模块太底层,而且改变了请求库本身的行为。MSW 在 Service Worker 层面拦截 HTTP 请求,更接近真实网络,无需修改应用代码。

# npm install -D msw

// src/mocks/handlers.ts — 定义拦截规则
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: 1, name: 'Alice', email: 'alice@example.com' },
      { id: 2, name: 'Bob', email: 'bob@example.com' },
    ]);
  }),

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

// src/mocks/server.ts — 测试环境服务器
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

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

// setupTests.ts — 启动/重置/关闭 MSW 服务器
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());  // 每次测试后重置覆盖
afterAll(() => server.close());

8. 自定义 Hook 测试

不能在测试文件中直接调用 Hook(Hook 只能在 React 函数组件内调用)。renderHook 提供了一个"容器组件"来运行 Hook。

// hooks/useCounter.ts
import { useState } from 'react';

export function useCounter(initial = 0) {
  const [count, setCount] = useState(initial);
  const increment = () => setCount((c) => c + 1);
  const decrement = () => setCount((c) => c - 1);
  const reset = () => setCount(initial);
  return { count, increment, decrement, reset };
}
// hooks/useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter', () => {
  it('初始值正确', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  it('increment 增加计数', () => {
    const { result } = renderHook(() => useCounter());

    // act 包裹所有会导致状态更新的操作
    act(() => {
      result.current.increment();
    });

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

  it('reset 恢复初始值', () => {
    const { result } = renderHook(() => useCounter(5));

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

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

9. 测试覆盖率

# 生成覆盖率报告
npm run coverage

# 输出示例:
# ----------------------------------------
# File         | % Stmts | % Branch | % Funcs | % Lines
# ----------------------------------------
# utils/       |   92.3  |   85.7   |   100   |   91.8
#  string.ts   |   100   |   100    |   100   |   100
#  format.ts   |   84.6  |   71.4   |   100   |   83.3

覆盖率指标说明:

⚠️
覆盖率不是目标本身。 100% 覆盖率但断言无意义的测试没有价值。追求合理的覆盖率(80% 以上),重点覆盖业务逻辑和边界条件。

10. 测试最佳实践

测试用户行为,不测实现细节

// ❌ 不好:测试组件内部 state(实现细节)
it('state 被正确更新', () => {
  const wrapper = shallow(<Counter />);
  wrapper.find('button').simulate('click');
  expect(wrapper.state('count')).toBe(1);  // 不好!
});

// ✅ 好:测试用户能看到的结果
it('点击增加按钮后屏幕显示新计数', async () => {
  const user = userEvent.setup();
  render(<Counter />);
  await user.click(screen.getByRole('button', { name: '增加' }));
  expect(screen.getByText('1')).toBeInTheDocument();  // 好!
});

什么不需要测试