Files
atlas-seller-kit/docs/guides/vitest-getting-started.md
2026-09-03 16:06:13 +08:00

370 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Vitest 入门:快速理解并上手前端自动化测试
> 面向 Atlas Seller Kit 的学习文档。本文解释 Vitest 是什么、为什么前端项目选它,以及如何在 `apps/web` 中安装、配置并写出第一批测试:断言、生命周期、mock、React 组件测试与覆盖率。读完即可在项目里独立写单测。
## 1. 一句话理解 Vitest
**Vitest = 测试运行器 + 断言库 + mock 工具,三合一,且构建在 Vite 之上。**
一个测试文件只涉及三个 API,10 秒即可看懂结构:
```ts
import { describe, expect, it } from 'vitest';
describe('被测对象', () => {
it('某个行为', () => {
expect(1 + 1).toBe(2);
});
});
```
- `describe`:把一组相关测试分组;
- `it`(别名 `test`):一条测试用例,失败即该用例不通过;
- `expect(实际值).断言(期望值)`:断言,全部断言通过用例才算通过。
运行器负责找到所有 `*.test.ts` 文件、执行、输出报告;断言库就是 `expect` 那一族方法;mock 工具是 `vi.*`,用来"替换掉"你不想在测试里真实执行的依赖(网络请求、定时器、localStorage 等)。
## 2. 为什么选 Vitest 而不是 Jest
Vitest 的 API 有意做成和 Jest 兼容(`describe/it/expect/vi`),从 Jest 迁移基本只改配置。差别在于底座:
| 维度 | Vitest | Jest |
|---|---|---|
| 构建/转换 | 复用 Vite 管线 | 自带转换器(需要额外配置 TS/JSX/别名) |
| `@` 别名、插件 | `vite.config.ts` 里已有的直接继承 | 要在 `jest.config` 里再抄一遍 |
| ESM / TS | 开箱即用,无需 `transform` 配置 | ESM 支持至今仍偏繁琐 |
| 冷启动与增量速度 | 快(与 dev server 同源,watch 模式只重跑受影响文件) | 相对慢 |
| 生态 | 与 Vite 生态(本项目技术栈)天然一致 | 生态最大,但多面向 webpack 时代 |
对本项目还有一条硬约束:`apps/web` 用的是 **Vite 8**,需要 **Vitest 4.1+**(Vite 8 支持自 4.1 引入;4.1 起 Vitest 直接使用项目里已安装的 Vite,版本天然对齐)。安装时不用刻意锁旧版,装最新稳定版即可。
## 3. 在 apps/web 中安装与配置
### 3.1 安装
```bash
cd apps/web
# 必装
pnpm add -D vitest
# 组件测试三件套(jsdom 提供 DOM 环境,后文 §8)
pnpm add -D jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom
# 覆盖率(后文 §9)
pnpm add -D @vitest/coverage-v8
```
### 3.2 配置:直接写在 vite.config.ts 里
Vitest 默认读取 `vite.config.ts`——这意味着 `@` 别名、React 插件**零配置直接继承**,这是选它的最大红利。只需加一个 `test` 字段:
```ts
/// <reference types="vitest/config" />
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
server: {
/* …原有配置不动… */
},
test: {
// Web 项目统一用 jsdom,纯逻辑测试在 jsdom 里跑也完全没问题
environment: 'jsdom',
// 每次测试前加载的公共准备代码(jest-dom 扩展、polyfill),见 §8
setupFiles: ['./src/test/setup.ts'],
coverage: {
include: ['src/**'],
reporter: ['text', 'html'],
},
},
});
```
顶部那行 `/// <reference types="vitest/config" />` 是给 TypeScript 的类型提示,让 `test` 字段获得补全和类型检查,不影响运行。
> ⚠️ 不要另建 `vitest.config.ts`:一旦它存在,`vite.config.ts` 会被**整体忽略(不合并)**,`@` 别名就会在测试里失效。本项目统一用 vite.config.ts 的 `test` 字段。
### 3.3 添加 npm scripts
`apps/web/package.json`:
```json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
```
- `pnpm test`:watch 模式,改代码自动重跑相关测试,适合开发时一直开着;
- `pnpm test:run`:跑一遍就退出,适合 CI 和提交前检查。
monorepo 里从仓库根目录执行:
```bash
pnpm --filter @atlas/web test:run
```
## 4. 写第一个测试
### 4.1 文件放哪、叫什么
Vitest 默认扫描两类文件:
- 与被测文件同目录的 `*.test.ts(x)` / `*.spec.ts(x)`,如 `src/utils/format.test.ts`;
- `__tests__/` 目录下的测试文件。
**本项目约定:与被测文件同目录同名加 `.test`**,如 `api.ts` 的测试叫 `api.test.ts`。就近放置,改哪个文件一眼就能看到它的测试。
### 4.2 用项目里的真实函数练手
`src/services/api.ts` 的 `apiErrorMessage` 是纯函数、无副作用,非常适合第一个测试(`src/services/api.test.ts`):
```ts
import { AxiosError } from 'axios';
import { describe, expect, it } from 'vitest';
import { apiErrorMessage } from '@/services/api';
describe('apiErrorMessage', () => {
it('Error 对象返回其 message', () => {
expect(apiErrorMessage(new Error('token 过期'))).toBe('token 过期');
});
it('字符串原样返回', () => {
expect(apiErrorMessage('boom')).toBe('boom');
});
it('HTTP 错误优先展示后端 error.message', () => {
const error = new AxiosError(
'Bad Request',
'ERR_BAD_REQUEST',
undefined,
undefined,
{ status: 400, data: { error: { message: '用户名已存在' } } } as never
);
expect(apiErrorMessage(error)).toBe('用户名已存在');
});
it('无响应体时展示状态码', () => {
const error = new AxiosError('Bad Request', 'ERR_BAD_REQUEST', undefined, undefined, {
status: 400,
} as never);
expect(apiErrorMessage(error)).toBe('请求失败(HTTP 400)');
});
});
```
运行 `pnpm test:run`,看到绿色 ✓ 即通过。故意把某个期望值改错再跑一次,观察失败报告——它会把实际值和期望值的差异打印出来,这是 Vitest 体验最好的部分之一。
## 5. 常用断言速查
| 断言 | 含义 |
|---|---|
| `toBe(x)` | 严格相等(`===`,适合原始值、同一引用) |
| `toEqual(x)` | 深度相等(对象/数组逐字段比较) |
| `toStrictEqual(x)` | 深度相等 + 检查 class 与 `undefined` 字段 |
| `toBeTruthy()` / `toBeFalsy()` | 真 / 假 |
| `toBeNull()` / `toBeUndefined()` / `toBeDefined()` | null / undefined / 非 undefined |
| `toContain(x)` | 包含(数组元素或字符串子串) |
| `toHaveLength(n)` | `.length` 等于 n |
| `toMatch(regex)` / `toMatchObject(obj)` | 匹配正则 / 部分字段匹配 |
| `toThrow(msg?)` | 抛出异常 |
| `toHaveBeenCalledWith(...)` | mock 函数以指定参数被调用(§7) |
两个惯用法:
```ts
// 异步断言:await + resolves/rejects
await expect(authApi.login(payload)).rejects.toThrow();
// 取反:任意断言前加 .not
expect(list).not.toContain('deleted');
```
## 6. 生命周期与批量用例
```ts
import { beforeEach, describe, it } from 'vitest';
describe('购物车', () => {
// 每条 it 前执行:适合"重置状态",保证用例互不影响
beforeEach(() => {
localStorage.clear();
});
// 同族还有 afterAll / beforeAll / afterEach,作用域限定在当前 describe 内
it('加入商品', () => { /* … */ });
it('移除商品', () => { /* … */ });
});
```
同一段逻辑测多组输入用 `it.each`,避免复制粘贴:
```ts
it.each([
['admin', true],
['', false],
[null, false],
])('isValidRole(%s) → %s', (role, expected) => {
expect(isValidRole(role)).toBe(expected);
});
```
## 7. Mock:隔离依赖的三种姿势
测试的核心纪律是**只测当前单元的逻辑,把外部依赖换成假的**。`vi` 提供三个层级的工具:
### 7.1 `vi.fn()`:造一个可断言的假函数
```ts
const onSuccess = vi.fn();
handle({ onSuccess });
expect(onSuccess).toHaveBeenCalledOnce();
expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({ id: 'p1' }));
```
### 7.2 `vi.mock()`:整个模块替换掉(最常用)
测试 `authApi` 时不应真的发 HTTP 请求,把 `@/services/api` 整体换掉:
```ts
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { authApi } from '@/services/auth';
import { api } from '@/services/api';
// 工厂函数返回"假模块":auth.ts 只导入了 api,替换它即可
vi.mock('@/services/api', () => ({
api: { post: vi.fn(), get: vi.fn(), patch: vi.fn(), delete: vi.fn() },
}));
const mockedPost = vi.mocked(api.post);
describe('authApi', () => {
beforeEach(() => {
vi.clearAllMocks(); // 每条用例前清空调用记录,防止互相污染
});
it('logout 调用 POST /auth/logout', async () => {
mockedPost.mockResolvedValueOnce(undefined);
await authApi.logout();
expect(api.post).toHaveBeenCalledWith('/auth/logout');
});
});
```
两个关键机制:
- **提升(hoisting)**:`vi.mock` 调用会被提升到文件最顶部,先于任何 import 生效,所以"先 import 后 mock"的写法是安全的;但代价是**工厂函数里不能引用外部变量**(会报错),确有需要用 `vi.hoisted()` 声明。
- `vi.mocked(api.post)` 只是给类型加了 mock 能力,指向的就是假模块里的那个 `vi.fn()`。
### 7.3 `vi.spyOn`:只监听(或临时替换)对象上的某个方法
```ts
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
// …执行会打日志的代码…
expect(spy).toHaveBeenCalled();
spy.mockRestore(); // 还原,避免影响其他用例
```
其他高频工具一行记:
```ts
vi.useFakeTimers(); // 冻结时间,setTimeout/Date 可控(记得配 vi.useRealTimers() 还原)
vi.stubGlobal('fetch', vi.fn()); // 替换全局对象(fetch、localStorage 等)
```
## 8. 测试 React 组件
组件测试用 Testing Library:**像用户一样操作页面(渲染→点击→断言看到的内容),而不是断言内部状态**。它在 jsdom 里渲染真实 React 组件(React 19 需要 `@testing-library/react` ≥ 16,安装最新版即可)。
公共准备代码 `src/test/setup.ts`(对应 §3.2 的 `setupFiles`):
```ts
import '@testing-library/jest-dom/vitest'; // 提供 toBeInTheDocument 等DOM断言
// antd 依赖 matchMedia,jsdom 未实现,需打桩
window.matchMedia ??= ((query: string) => ({
matches: false,
media: query,
addEventListener: () => {},
removeEventListener: () => {},
})) as typeof window.matchMedia;
```
一个组件测试的样子:
```tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
function ConfirmButton({ onConfirm }: { onConfirm: () => void }) {
return <button onClick={onConfirm}>确认删除</button>;
}
describe('ConfirmButton', () => {
it('点击后触发 onConfirm', async () => {
const onConfirm = vi.fn();
render(<ConfirmButton onConfirm={onConfirm} />);
await userEvent.click(screen.getByRole('button', { name: '确认删除' }));
expect(onConfirm).toHaveBeenCalledOnce();
});
});
```
要点:
- `render` 渲染组件,`screen.getByRole/getByText/getByLabelText` 按用户视角查询元素(查不到会抛错,用 `queryBy*` 断言"不存在");
- `userEvent` 模拟真实交互(点击、输入),必须是 `await`;
- 测页面级组件(如 `LoginPage`)时,若内部用到 Router/Session 上下文,需要先包一层 Provider 或 mock 对应模块——这也是组件测试的主要成本所在,建议**只对关键交互路径写组件测试**。
## 9. 日常运行与覆盖率
```bash
pnpm test # watch 模式(开发时开着)
pnpm test:run # 单次运行
pnpm test:run src/services # 只跑路径匹配的文件
pnpm test:run -t 'logout' # 只跑名字含 logout 的用例
pnpm test:run --related # 只测本次 git 变更涉及的文件(提交前自查很好用)
pnpm test:coverage # 覆盖率报告(终端表格 + html/coverage/index.html)
pnpm test -- --ui # 浏览器图形界面(需 pnpm add -D @vitest/ui)
```
覆盖率数字只是参考:`utils`、`services` 这类逻辑层应追求高覆盖;UI 组件靠少量关键交互测试兜底即可,不必为数字凑行数。
## 10. 在本项目的落地建议
1. **优先测两层**:`src/utils/` 纯函数(成本最低、收益最高);`src/services/` 服务层——mock 掉 `@/services/api` 后,401 刷新重放、信封解包(`unwrapEnvelope`)、`apiErrorMessage` 这些核心逻辑都能稳定验证。
2. **组件测试抓大放小**:登录/注册表单校验与提交、路由跳转等关键路径值得写;纯展示组件不强制。
3. **会话相关逻辑先打桩再测**:`api.ts` 刷新失败会执行 `window.location.assign('/login')`,jsdom 里 location 不可随意改写,测试时用 `vi.stubGlobal` 或把跳转逻辑抽成可注入的函数。
4. **接入流程**:本地开发开 `pnpm test`;提交前 `pnpm test:run --related`;CI 里跑 `test:coverage`。后续可在 `turbo.json` 中加 `test` 任务,实现根目录 `turbo run test` 一键跑全部 workspace。
5. **与 typecheck 分工**:`tsc` 管"类型对不对",Vitest 管"行为对不对",两者互补,提交前都跑。
## 11. 常见坑速查
| 症状 | 原因与解法 |
|---|---|
| 测试里 `@/...` 导入报错"找不到模块" | 建了 `vitest.config.ts` 导致 `vite.config.ts` 被整体忽略。删掉它,统一写在 `vite.config.ts` 的 `test` 字段(见 §3.2) |
| `vi.mock` 工厂里引用了外部变量报错 | `vi.mock` 会被提升到文件顶部,工厂不能闭包引用外部变量;需要共享值时用 `vi.hoisted()` |
| antd 组件测试报 `matchMedia is not a function` | jsdom 未实现该 API,在 `src/test/setup.ts` 里打桩(见 §8) |
| `toBeInTheDocument` 类型报错 | 缺 `import '@testing-library/jest-dom/vitest'`,确认 setup 文件已配置 |
| 测 `setTimeout`/节流时用例"卡住"或时序不稳 | 用 `vi.useFakeTimers()` 冻结时间并手动 `vi.advanceTimersByTime()` |
| 覆盖率命令报错找不到 provider | 覆盖率是可选依赖:`pnpm add -D @vitest/coverage-v8` |
| 想让 `describe/it/expect` 免导入直接用 | 配置 `test.globals: true`,并在 tsconfig 的 `compilerOptions.types` 加 `"vitest/globals"`。本项目建议保持显式导入(不折腾 tsconfig,IDE 跳转也更准) |
## 12. 参考资料
- [Vitest 官方文档](https://vitest.dev/guide/)(Getting Started / Guide / API 三栏足够日常使用)
- [Vitest 4.1 发布公告](https://vitest.dev/blog/vitest-4-1.html)(Vite 8 支持与版本对齐说明)
- [Testing Library 官方文档](https://testing-library.com/docs/react-testing-library/intro/)(查询优先级与 userEvent 用法)
- 本项目接口契约:`docs/api/auth.md`、`docs/api/conventions.md`