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

15 KiB
Raw Permalink Blame History

Vitest 入门:快速理解并上手前端自动化测试

面向 Atlas Seller Kit 的学习文档。本文解释 Vitest 是什么、为什么前端项目选它,以及如何在 apps/web 中安装、配置并写出第一批测试:断言、生命周期、mock、React 组件测试与覆盖率。读完即可在项目里独立写单测。

1. 一句话理解 Vitest

Vitest = 测试运行器 + 断言库 + mock 工具,三合一,且构建在 Vite 之上。

一个测试文件只涉及三个 API,10 秒即可看懂结构:

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 安装

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 字段:

/// <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:

{
    "scripts": {
        "test": "vitest",
        "test:run": "vitest run",
        "test:coverage": "vitest run --coverage"
    }
}
  • pnpm test:watch 模式,改代码自动重跑相关测试,适合开发时一直开着;
  • pnpm test:run:跑一遍就退出,适合 CI 和提交前检查。

monorepo 里从仓库根目录执行:

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):

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)

两个惯用法:

// 异步断言:await + resolves/rejects
await expect(authApi.login(payload)).rejects.toThrow();

// 取反:任意断言前加 .not
expect(list).not.toContain('deleted');

6. 生命周期与批量用例

import { beforeEach, describe, it } from 'vitest';

describe('购物车', () => {
    // 每条 it 前执行:适合"重置状态",保证用例互不影响
    beforeEach(() => {
        localStorage.clear();
    });

    // 同族还有 afterAll / beforeAll / afterEach,作用域限定在当前 describe 内
    it('加入商品', () => { /* … */ });
    it('移除商品', () => { /* … */ });
});

同一段逻辑测多组输入用 it.each,避免复制粘贴:

it.each([
    ['admin', true],
    ['', false],
    [null, false],
])('isValidRole(%s) → %s', (role, expected) => {
    expect(isValidRole(role)).toBe(expected);
});

7. Mock:隔离依赖的三种姿势

测试的核心纪律是只测当前单元的逻辑,把外部依赖换成假的。vi 提供三个层级的工具:

7.1 vi.fn():造一个可断言的假函数

const onSuccess = vi.fn();
handle({ onSuccess });
expect(onSuccess).toHaveBeenCalledOnce();
expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({ id: 'p1' }));

7.2 vi.mock():整个模块替换掉(最常用)

测试 authApi 时不应真的发 HTTP 请求,把 @/services/api 整体换掉:

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:只监听(或临时替换)对象上的某个方法

const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
// …执行会打日志的代码…
expect(spy).toHaveBeenCalled();
spy.mockRestore(); // 还原,避免影响其他用例

其他高频工具一行记:

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):

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;

一个组件测试的样子:

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. 日常运行与覆盖率

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. 参考资料