陌上人如玉
公子世无双

10.4 测试

10.4 测试

一句话:React 测试的核心思想是「像用户一样测试」——用 Testing Library 按「角色/文本」查元素、用 userEvent 模拟真实交互、断言「用户能看到什么」,而不是断言「组件内部的实现细节」。

一、测试金字塔

        ▲  少
       ╱ ╲     E2E(Playwright/Cypress)
      ╱───╲    · 跑真实浏览器、真实后端(或 Mock)
     ╱     ╲   · 覆盖「关键业务路径」(登录、下单、支付)
    ╱       ╲  · 慢、脆、但置信度最高
   ╱─────────╲
  ╱           ╲ 集成/组件测试(Vitest + Testing Library)
 ╱             ╲ · 渲染组件、模拟交互、断言 UI
╱───────────────╲· 数量最多(性价比最高)
╱                 ╲
╱───────────────────╲ 单元测试(纯函数)
                      · 工具函数、reducer、自定义 Hook 逻辑
        ▼  多             · 快、稳定、但覆盖不到 UI

⭐ 实践建议:
· 纯逻辑(utils / reducer / 格式化)→ 单元测试(多写)
· 组件行为(渲染 + 交互 + 状态)→ 组件测试(主力)
· 关键流程(登录/下单)→ E2E(少而精)
· 不要追求「100% 覆盖率」——要覆盖「关键路径与易错分支」

二、Testing Library 的核心思想

⭐ 一句话:「测试应该越接近用户使用软件的方式,就越有信心。」

因此它提倡:
① 用「用户能感知的东西」定位元素 —— 角色(role)、标签(label)、文本(text)
   ❌ 不用「类名、id、组件内部 state」定位
② 用「用户能做的操作」交互 —— 点击、输入、Tab、回车
   ❌ 不直接调用「组件内部方法」或「setState」
③ 断言「用户能看到的结果」 —— 文本出现、元素可见、无障碍属性
   ❌ 不断言「内部实现」(某个 state 是几、某个方法被调了几次)

好处:
· 重构组件内部实现时,测试不会大面积失败(降低维护成本)
· 测试能发现「真正的用户可见 bug」(而不是「实现细节变了」)
· 天然推动无障碍(用 role/label 查询 → 会引导你写正确的语义标签)
// ✅ 好的测试:像用户一样
test('点击按钮后显示欢迎语', async () => {
  const user = userEvent.setup();
  render(<Greeting name="张三" />);

  await user.click(screen.getByRole('button', { name: /打招呼/ }));

  expect(screen.getByText('你好,张三!')).toBeInTheDocument();
});

// ❌ 不好的测试:断言实现细节
test('bad', () => {
  const { container } = render(<Greeting name="张三" />);
  container.querySelector('.greeting__inner')!.dispatchEvent(new MouseEvent('click'));
  expect(container.querySelector('.msg')!.textContent).toBe('你好,张三!');
});

三、查询方式的优先级

⭐ 从「最像用户」到「最像实现」的推荐顺序:

① getByRole(首选)
   screen.getByRole('button', { name: '提交' })
   → 既像用户(看到「按钮」),又验证了无障碍语义

② getByLabelText(表单元素首选)
   screen.getByLabelText('用户名')
   → 验证了 label 与 input 的关联(无障碍)

③ getByPlaceholderText / getByText
   screen.getByPlaceholderText('请输入关键词')
   screen.getByText('暂无数据')

④ getByDisplayValue / getByAltText / getByTitle
   screen.getByAltText('用户头像')

⑤ getByTestId(最后手段)
   screen.getByTestId('custom-widget')
   → 只在「无法用前几种方式稳定定位」时用
查询 何时用
getByRole 首选(按钮、输入框、链接、标题、列表)
getByLabelText 表单字段(验证 a11y 关联)
getByText 展示性文本
getByPlaceholderText 有 placeholder 的输入框
getByAltText 图片
getByTestId 最后手段(无合适语义时)
// 三种变体的区别(重要)
getByText('x');      // 找不到就【抛错】(同步)
queryByText('x');    // 找不到返回 null(用于「断言不存在」)
findByText('x');     // 返回 Promise(等到出现,默认 1s 超时)→ 用于异步

// ✅ 「断言不存在」必须用 queryBy
expect(screen.queryByText('加载中')).not.toBeInTheDocument();

// ✅ 「等某个东西出现」用 findBy
expect(await screen.findByText('张三')).toBeInTheDocument();

四、Vitest 配置

npm i -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom \
         jsdom @vitejs/plugin-react
// vitest.config.ts(或写在 vite.config.ts 里)
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',                 // ⭐ 组件测试需要 DOM 环境
    globals: true,                         // 用全局的 describe/it/expect
    setupFiles: ['./src/test/setup.ts'],   // 全局 setup
    css: false,                            // 测试里不处理 CSS(更快)
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html'],
      exclude: ['**/*.d.ts', '**/test/**', '**/*.stories.tsx'],
    },
  },
});
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';        // ⭐ 扩展 expect(toBeInTheDocument 等)
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

afterEach(() => cleanup());                       // ⭐ 每个用例后清理 DOM
// package.json
{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage"
  }
}

五、组件测试的完整示例

// TodoList.tsx
export function TodoList({ initial = [] }: { initial?: Todo[] }) {
  const [todos, setTodos] = useState(initial);
  const [text, setText] = useState('');

  const add = () => {
    if (!text.trim()) return;
    setTodos((prev) => [...prev, { id: nanoid(), text, done: false }]);
    setText('');
  };

  return (
    <div>
      <label htmlFor="new-todo">新任务</label>
      <input
        id="new-todo"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="输入任务"
      />
      <button onClick={add} disabled={!text.trim()}>添加</button>

      {todos.length === 0 ? (
        暂无任务
      ) : (
        <ul>
          {todos.map((t) => (
            <li key={t.id}>
              <input
                type="checkbox"
                checked={t.done}
                onChange={() => setTodos((prev) => prev.map((x) => (x.id === t.id ? { ...x, done: !x.done } : x)))}
                aria-label={`标记「${t.text}」为完成`}
              />
              {t.text}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
// TodoList.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import { TodoList } from './TodoList';

describe('TodoList', () => {
  it('空列表时显示空状态', () => {
    render(<TodoList />);
    expect(screen.getByText('暂无任务')).toBeInTheDocument();
    expect(screen.queryByRole('list')).not.toBeInTheDocument();     // ⭐ 断言不存在用 queryBy
  });

  it('输入为空时「添加」按钮禁用', async () => {
    const user = userEvent.setup();
    render(<TodoList />);

    expect(screen.getByRole('button', { name: '添加' })).toBeDisabled();

    await user.type(screen.getByLabelText('新任务'), '学习 React');
    expect(screen.getByRole('button', { name: '添加' })).toBeEnabled();
  });

  it('可以添加任务并清空输入框', async () => {
    const user = userEvent.setup();
    render(<TodoList />);

    const input = screen.getByLabelText('新任务');
    await user.type(input, '学习 React');
    await user.click(screen.getByRole('button', { name: '添加' }));

    expect(screen.getByText('学习 React')).toBeInTheDocument();
    expect(input).toHaveValue('');                                  // ⭐ 输入框被清空
    expect(screen.queryByText('暂无任务')).not.toBeInTheDocument();
  });

  it('可以勾选任务', async () => {
    const user = userEvent.setup();
    render(<TodoList initial={[{ id: '1', text: '读书', done: false }]} />);

    const checkbox = screen.getByRole('checkbox', { name: /标记「读书」为完成/ });
    expect(checkbox).not.toBeChecked();

    await user.click(checkbox);
    expect(checkbox).toBeChecked();
  });
});
// ✅ 异步测试(数据加载)
it('加载完成后显示列表', async () => {
  vi.mocked(api.getUsers).mockResolvedValue([{ id: '1', name: '张三' }]);

  render(<UserList />);

  expect(screen.getByText('加载中…')).toBeInTheDocument();          // 初始状态
  expect(await screen.findByText('张三')).toBeInTheDocument();       // ⭐ 等异步结果
  expect(screen.queryByText('加载中…')).not.toBeInTheDocument();     // 加载态消失
});

六、userEvent vs fireEvent

维度 fireEvent userEvent
模拟程度 「直接派发一个事件」 「模拟用户的一整套行为」
输入 fireEvent.change(input, { target: { value: 'x' } })(一次给全值) await user.type(input, 'x')(逐字符 + 焦点 + 中间事件)
点击 fireEvent.click(el) await user.click(el)(含 pointer 事件、焦点变化)
是否要 await ❌ ✅(都是异步)
推荐 做「特定底层事件」时用 默认用它
// ✅ 推荐 userEvent:更接近真实用户(能发现「只有真实交互才暴露的 bug」)
const user = userEvent.setup();
await user.type(input, 'hello');        // 逐个字符输入
await user.click(button);               // 完整的点击流程
await user.tab();                        // Tab 键切换焦点
await user.keyboard('{Enter}');          // 键盘
await user.selectOptions(select, 'bj');  // 选择
await user.upload(fileInput, file);      // 上传
await user.hover(el);                    // 悬停

七、Mock

// ① Mock 函数(vi.fn)
const handleClick = vi.fn();
render(<Button onClick={handleClick}>点我</Button>);
await userEvent.click(screen.getByRole('button'));

expect(handleClick).toHaveBeenCalledTimes(1);
expect(handleClick).toHaveBeenCalledWith(expect.any(Object));        // 参数是事件对象

// ② Mock 模块(vi.mock)
vi.mock('@/api/user', () => ({
  getUsers: vi.fn().mockResolvedValue([{ id: '1', name: '张三' }]),
}));

// ③ Mock 定时器(避免真的等 3 秒)
vi.useFakeTimers();
render(<DebouncedSearch />);
await userEvent.type(screen.getByRole('textbox'), 'react');
vi.advanceTimersByTime(300);                                         // ⭐ 快进
expect(api.search).toHaveBeenCalledWith('react');
vi.useRealTimers();

// ④ 用 MSW(Mock Service Worker)拦截「网络请求」——最接近真实
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

const server = setupServer(
  http.get('/api/users', () => HttpResponse.json([{ id: '1', name: '张三' }]))
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
⭐ Mock 的三条原则:
① 【优先 Mock「网络边界」】(MSW)而不是「Mock 掉整个模块」
   → 因为组件内部的逻辑仍被真实执行(测试更有价值)
② 【只 Mock「慢/不稳定/外部」的东西】—— 定时器、网络、随机、时间和日期
③ 【不要 Mock「被测组件本身」】—— 那样等于没测

八、E2E(Playwright)

// e2e/login.spec.ts
import { test, expect } from '@playwright/test';

test('用户可以登录并看到仪表盘', async ({ page }) => {
  await page.goto('/login');

  await page.getByLabel('邮箱').fill('user@example.com');
  await page.getByLabel('密码').fill('password123');
  await page.getByRole('button', { name: '登录' }).click();

  await expect(page.getByRole('heading', { name: '仪表盘' })).toBeVisible();
  await expect(page).toHaveURL('/dashboard');
});
⭐ E2E 的取舍:
✅ 优点:覆盖「真实浏览器 + 真实后端」→ 置信度最高(能发现集成问题)
❌ 缺点:慢(分钟级)、脆(受网络/环境影响)、维护成本高

✅ 实践:
· 只覆盖「关键业务路径」(登录、核心流程、支付)
· 用「数据准备 API」而非「UI 操作」来构造前置状态(更快更稳)
· 在 CI 里跑(本地只在需要时跑)
· 用「测试专用账号/环境」避免污染真实数据

九、测什么、不测什么

✅ 值得测:
① 关键业务逻辑(utils、reducer、计算、格式化)
② 组件的「用户可见行为」(渲染、交互、状态变化、错误态)
③ 边界与异常(空数据、加载中、错误、超长文本)
④ 无障碍(用 role/label 查询天然覆盖了一部分)
⑤ 自定义 Hook 的逻辑(用 renderHook)

❌ 不值得测:
① 「内部实现」(某个 state 是几、私有方法被调了几次)
② 第三方库的行为(那是库的测试职责)
③ 「样式细节」(颜色/间距 —— 用视觉回归测试更合适)
④ 「大量组合的穷举」(选关键组合)
⑤ 覆盖率数字本身(不要为了覆盖率写无意义的断言)
// ✅ 测试自定义 Hook(用 renderHook)
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

test('useCounter 可以增减', () => {
  const { result } = renderHook(() => useCounter(0));

  act(() => result.current.increment());
  expect(result.current.count).toBe(1);

  act(() => result.current.decrement());
  expect(result.current.count).toBe(0);
});

十、常见坑

// ① 用容器查询(container.querySelector)定位元素
container.querySelector('.btn')!.click();          // ❌ 脆弱、测了实现
screen.getByRole('button', { name: '提交' });       // ✅

// ② 忘记 await userEvent 的操作(都是异步)
await user.click(button);                            // ✅
user.click(button);                                  // ⚠️ 可能断言在操作前执行

// ③ 「断言不存在」用了 getBy(会抛错而不是返回 null)
expect(screen.getByText('加载中')).not.toBeInTheDocument();   // ❌ 找不到就抛错
expect(screen.queryByText('加载中')).not.toBeInTheDocument(); // ✅

// ④ 用「固定延迟」等待异步(不稳定的根源)
await new Promise((r) => setTimeout(r, 1000));       // ❌
expect(screen.getByText('张三')).toBeInTheDocument();
expect(await screen.findByText('张三')).toBeInTheDocument();   // ✅

// ⑤ 忘了 cleanup(多个用例互相污染 DOM)
// ⑥ 每个用例都「重新创建 store/QueryClient」(否则状态泄漏)
const renderWithProviders = (ui: React.ReactElement) =>
  render(
    <QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
      {ui}
    </QueryClientProvider>
  );
// ⭐ 注意 retry: false(否则失败用例会等重试超时)

// ⑦ Mock 了「被测组件」自身(等于没测)
// ⑧ 测试「实现细节」(重构就全红 → 测试成为负担)
// ⑨ 不用 userEvent 而用 fireEvent(遗漏真实交互的中间事件)
// ⑩ 用 testid 定位一切(失去了「像用户一样测试」的价值)
// ⑪ 测试里用真实定时器(慢且不稳定)→ 用 fake timers
// ⑫ 断言「CSS 类名」(样式重构就红)→ 应该断言「可见性/无障碍属性」
// ⑬ 没有测「错误态 / 空态 / 加载态」(最常见的线上问题)
// ⑭ 用 MSW 之外的方式「Mock 整个请求模块」(组件内部逻辑没被真实执行)
// ⑮ 在测试里「依赖执行顺序」(用例之间应互相独立)
// ⑯ E2E 里用 UI 操作构造数据(慢且脆)→ 用 API 准备
// ⑰ 认为「覆盖率 100% 就等于没问题」(覆盖率只是「行被执行过」)
// ⑱ 测试跑在 CI 里但没有「失败就阻断」的门禁

十一、面试延伸

  1. 「React 组件怎么测试?」
    用 Vitest + Testing Library:render 渲染组件、用 screen.getByRole/getByLabelText/getByText 定位元素、用 userEvent 模拟真实交互(点击、输入、Tab)、用 expect 断言「用户能看到的结果」(文本出现、元素可见、toBeChecked/toHaveValue 等)。异步用 findBy*(等元素出现)或 waitFor。核心思想是「像用户一样测试」——按语义角色查询、按用户操作交互、断言可见结果,而不是断言内部实现。

  2. 「为什么推荐 Testing Library 而不是 Enzyme?」
    因为它的哲学不同:Enzyme 鼓励「测试实现细节」(查 state、调用内部方法、按 CSS 选择器找节点),而 Testing Library 鼓励「测试用户可见行为」(按 role/label/text 查询、模拟真实交互)。后果是:① Enzyme 的测试在重构后会大面积失败(因为实现变了),维护成本高;② Testing Library 的测试能发现真正的用户可见 bug;③ 用 role/label 查询会自然推动无障碍(迫使你写正确的语义标签)。一句话:Enzyme 测「组件怎么实现」,Testing Library 测「用户能不能用」。

  3. 「查询元素的优先级是什么?」
    从「最像用户」到「最像实现」:getByRole(首选,同时验证无障碍语义)→ getByLabelText(表单)→ getByPlaceholderText / getByText → getByDisplayValue / getByAltText / getByTitle → getByTestId(最后手段)。getByTestId 应该少用——只有当「没有合适的语义/文本可查」时(如「一个没有可见文本的自定义控件」)才用它,并且应该给 data-testid 起有意义的名字。

  4. 「getBy / queryBy / findBy 有什么区别?」
    getBy——同步查找,找不到就抛错(用于「元素一定存在」的断言);queryBy——同步查找,找不到返回 null(用于「断言不存在」:expect(queryByText('x')).not.toBeInTheDocument());findBy——异步(返回 Promise),会等元素出现(默认 1 秒超时,可用 waitFor 定制),用于「异步渲染后才出现的元素」。三个变体都有 All 版本(getAllBy/queryAllBy/findAllBy)用于「多个匹配」。

  5. 「userEvent 和 fireEvent 有什么区别?」
    fireEvent 是「直接派发一个事件」(如 fireEvent.change(input, { target: { value: 'x' } }) 一次性把值设好),userEvent 是「模拟用户的一整套行为」——如 user.type(input, 'x') 会「聚焦、逐字符输入、派发中间的 keydown/keypress/input 事件、处理光标位置」。因此:① userEvent 更接近真实用户,能发现「只有真实交互才暴露的 bug」(如「输入每个字符都触发请求」的性能问题);② userEvent 是异步的(必须 await);③ 默认用 userEvent,只有需要「派发特定底层事件」(如某些浏览器的特殊事件)时才用 fireEvent。

  6. 「测试里怎么 Mock 网络请求?」
    推荐用 MSW(Mock Service Worker)——它在「网络层」拦截请求(浏览器用 Service Worker、Node 用 msw/node 的拦截器),组件内部的逻辑(含 request 封装、状态管理、缓存)都会真实执行。相比之下「把整个 API 模块 vi.mock 掉」会「跳过封装层」,测试的置信度更低。MSW 的用法是「setupServer 定义 handlers → beforeAll(server.listen) → afterEach(server.resetHandlers) → afterAll(server.close)」,并在具体用例里用 server.use(...) 覆盖特定接口。

  7. 「测什么、不测什么?」
    值得测:① 关键业务逻辑(utils、reducer、计算、格式化);② 组件的用户可见行为(渲染、交互、状态变化、错误态/空态/加载态);③ 边界与异常(空数据、超长文本、权限不足);④ 自定义 Hook 的逻辑(renderHook)。不值得测:① 内部实现(某个 state 是几、私有方法被调几次);② 第三方库的行为;③ 样式细节(应该用视觉回归测试);④ 组合的穷举;⑤ 覆盖率数字本身(覆盖率只表示「行被执行过」,不代表「逻辑正确」)。

  8. 「测试如何避免『脆弱』(重构就大面积失败)?」
    五条:① 断言「用户可见行为」而不是实现(用 getByRole/getByText 而不是 querySelector('.cls')、不断言 CSS 类名);② 按「角色/标签」查询(元素结构变了仍能找到);③ 等待用 findBy*/waitFor 而不是固定延时(避免时序脆弱);④ Mock 边界而不是 Mock 一切(MSW 拦截网络,组件内部保持真实);⑤ 测试之间互相独立(每个用例重新渲染、重建 store/QueryClient,afterEach(cleanup))。判断标准是「组件内部重构后,测试是否还能通过」——能通过说明测的是「行为」,不能通过说明测的是「实现」。

一句话速记

用 Vitest + Testing Library(像用户一样测试):定位用 getByRole(首选)/getByLabelText/getByText,getByTestId 是最后手段;三个变体是「getBy 找不到就抛错、queryBy 找不到返回 null(用于断言不存在)、findBy 异步等待(await)」;交互用 userEvent(异步、更真实,比 fireEvent 好);Mock 优先用 MSW 拦截网络边界(或用 vi.fn/vi.mock/假定时器),别 Mock 被测组件自身;断言「用户能看到的结果」而不是实现细节(否则重构就全红);必测「错误态/空态/加载态」;不用固定延时等待;E2E 只覆盖关键路径(Playwright);不用追求 100% 覆盖率。

赞(0) 打赏
未经允许不得转载:陌上寒 » 10.4 测试

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫