前端单元测试实践:用 Vitest 把测试写成资产而不是负担
【摘要】 前端单元测试实践:用 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
六、让测试跑得稳
- 快照测试别大范围用:
toMatchSnapshot()一把梭,重构时会产出几百行无意义 diff,优先toMatchInlineSnapshot并缩小范围; - 不依赖执行顺序与当前时间:随机数、
Date.now()、Math.random()一律 mock,否则本地绿、CI 红; - 每个用例自己准备数据:共享
beforeAll里的可变对象是 flaky 的头号来源; - CI 上限制并发:
--maxWorkers=50%比默认占满更稳,OOM 通常就是并行开太多; - 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 秒级反馈,团队才愿意长期维护。
总结
测试的价值来自信号质量,不来自数量。三条原则:
- 测行为不测实现,优先语义查询而非
data-testid; - 只在边界处 mock(HTTP、时间、随机),内部函数尽量真跑;
- 覆盖率看分支不看行,flaky 零容忍。
把测试当成"改代码时的安全带"而不是"KPI 的数字",它才真正开始产生回报。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱:
cloudbbs@huaweicloud.com
- 点赞
- 收藏
- 关注作者
评论(0)