# 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 /// 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'], }, }, }); ``` 顶部那行 `/// ` 是给 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 ; } describe('ConfirmButton', () => { it('点击后触发 onConfirm', async () => { const onConfirm = vi.fn(); render(); 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`