前端单元测试实践:用 Vitest 把测试写成资产而不是负担

举报
yd_231768394 发表于 2026/11/02 21:15:58 2026/11/02
【摘要】 前端单元测试实践:用 Vitest 把测试写成资产而不是负担 前言"写了测试反而更慢了"是很多团队放弃测试的真实原因——测的是实现细节,改一行样式挂掉十个用例。本文讲怎么用 Vitest + Testing Library 写出改代码不会误报、坏代码一定能拦住的测试。 一、先想清楚测什么测试金字塔在前后端混合项目里的落脚点:层级测什么占比工具单元测试纯函数、工具库、状态计算60%Vites...

前端单元测试实践:用 Vitest 把测试写成资产而不是负担

前言

"写了测试反而更慢了"是很多团队放弃测试的真实原因——测的是实现细节,改一行样式挂掉十个用例。本文讲怎么用 Vitest + Testing Library 写出改代码不会误报、坏代码一定能拦住的测试。

一、先想清楚测什么

测试金字塔在前后端混合项目里的落脚点:

层级 测什么 占比 工具
单元测试 纯函数、工具库、状态计算 60% Vitest
组件测试 渲染 + 交互 + 数据流 30% Testing Library
E2E 关键业务链路 10% Playwright

判断标准很简单:这段逻辑出错时用户会不会痛? 痛就值得测;纯样式的 div 嵌套不值得。

二、配置:一份能直接用的 vitest.config.ts

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

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',   // happy-dom 更快,jsdom 兼容性更好
    globals: true,          // 免去 import { describe, it, expect }
    setupFiles: ['./test/setup.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'lcov'],
      exclude: ['**/*.d.ts', '**/*.config.*', 'src/mocks/**'],
    },
  },
});

全局清理放进 setup,比每个文件重复写省事:

// test/setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

afterEach(() => cleanup());

三、组件测试:按用户视角写断言

查询优先级(从高到低):getByRole → getByLabelText → getByPlaceholderText → getByText → getByTestId。前几个是"用户能感知的语义",写出来的测试不会随 DOM 结构调整而崩;data-testid 是最后的逃生口。

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

it('提交空表单时提示必填', async () => {
  const user = userEvent.setup();
  render(<LoginForm onSubmit={vi.fn()} />);

  await user.click(screen.getByRole('button', { name: /登录/ }));

  expect(await screen.findByText('请输入邮箱')).toBeInTheDocument();
});

三个细节:

  • 用 userEvent 而不是 fireEvent:前者模拟真实事件序列(focus → keydown → input → keyup),很多"点击没反应"的伪 bug 就此消失;
  • 用 findBy* 而不是 waitFor 包 getBy*:异步断言更直观,失败信息也更清楚;
  • 断言行为,不断言实现:别断言 store.count === 1,断言"页面上显示 1"。

四、Mock:容易踩的三个坑

1. vi.mock 会被提升到文件顶部,内部引用外部变量会报错,需要 vi.hoisted:

const { fetchUser } = vi.hoisted(() => ({ fetchUser: vi.fn() }));

vi.mock('./api', () => ({ fetchUser }));

fetchUser.mockResolvedValue({ id: 1, name: 'Ada' });

2. 不要手写 fetch mock,用 msw 在 HTTP 层拦截,测的仍是真实代码路径:

import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';

const server = setupServer(
  http.get('/api/user/1', () => HttpResponse.json({ id: 1, name: 'Ada' })),
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

3. 定时器要用假时钟,否则 debounce 测试要么等 5 秒,要么直接挂:

vi.useFakeTimers();
fireSearch('react');
vi.advanceTimersByTime(300);
expect(onSearch).toHaveBeenCalledWith('react');
vi.useRealTimers();

五、覆盖率:看数字,但别追数字

  • 行覆盖率 80% 是有意义的门槛,100% 是有害的执念:为凑覆盖率写的 expect(true).toBe(true) 只会制造维护成本;
  • 真正值得盯的是分支覆盖率:if/else、??、可选链的兜底路径才是 bug 高发区;
  • 提交前用 --changed 只跑受影响用例,全量留给 CI。
pnpm vitest --changed HEAD~1
pnpm vitest run --coverage

六、让测试跑得稳

  1. 快照测试别大范围用:toMatchSnapshot() 一把梭,重构时会产出几百行无意义 diff,优先 toMatchInlineSnapshot 并缩小范围;
  2. 不依赖执行顺序与当前时间:随机数、Date.now()、Math.random() 一律 mock,否则本地绿、CI 红;
  3. 每个用例自己准备数据:共享 beforeAll 里的可变对象是 flaky 的头号来源;
  4. CI 上限制并发:--maxWorkers=50% 比默认占满更稳,OOM 通常就是并行开太多;
  5. flaky 用例立刻修或删:一旦允许 retry,团队就会逐渐无视红灯。

七、CI 集成

- run: pnpm install --frozen-lockfile
- run: pnpm vitest run --coverage --maxWorkers=2
- uses: actions/upload-artifact@v4
  if: always()
  with:
    name: coverage
    path: coverage/

把覆盖率当"趋势观察"而非"卡点门槛",配合 --changed 做到 PR 秒级反馈,团队才愿意长期维护。

总结

测试的价值来自信号质量,不来自数量。三条原则:

  1. 测行为不测实现,优先语义查询而非 data-testid;
  2. 只在边界处 mock(HTTP、时间、随机),内部函数尽量真跑;
  3. 覆盖率看分支不看行,flaky 零容忍。

把测试当成"改代码时的安全带"而不是"KPI 的数字",它才真正开始产生回报。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。