核心知识点
一、基础概念
1. Vitest 简介与定位
- 说明: Vitest 是一个由 Vite 驱动的 极速 单元测试框架。它旨在利用 Vite 生态系统的优势(如快速的 HMR、基于 esbuild 的转译)来提供卓越的开发体验 (DX) 和性能。它的 API 设计与 Jest 高度兼容,使得从 Jest 迁移相对平滑。
- 定位: 主要用于前端项目(尤其与 Vite 深度绑定的项目,如 Vue, React, Svelte 等)以及 Node.js 库的单元测试、集成测试。
2. 与 Jest、Mocha 等测试框架的对比
- 说明:
- Jest: 功能全面,生态成熟,配置相对独立。Vitest 兼容其大部分 API,但启动和执行速度通常更快,尤其是在 Vite 项目中,因为它共享 Vite 的配置和转换管道。Jest 需要额外的配置(如 Babel 或 ts-jest)来处理 TypeScript/JSX,而 Vitest 则开箱即用。
- Mocha: 灵活、历史悠久的测试运行器,通常需要配合断言库 (Chai) 和 Mock 库 (Sinon) 使用。Vitest 则内置了这些能力,提供更一体化的体验。
- 对比优势:
- 速度: 利用 Vite 的按需编译和缓存,测试启动和重新运行速度快。
- 配置: 可直接复用
vite.config.js
中的配置(如别名alias
、全局变量define
等),减少重复配置。 - HMR: 支持测试热更新,修改代码后只重新运行相关的测试。
- 原生 ESM: 对 ES Modules 有更好的原生支持。
- 内置能力: 开箱即用的 TypeScript/JSX 支持,内置 Chai 断言和 Sinon mock 功能(通过
vi
对象)。
3. 适用场景与优势
- 适用场景:
- 使用 Vite 构建工具的项目(最佳选择)。
- 对测试执行速度有较高要求的项目。
- 希望利用现代 JavaScript 特性(ESM, TS, JSX)进行测试的项目。
- 寻求与 Jest 相似 API 以降低学习/迁移成本的团队。
- 优势:
- 极快的测试执行速度和 HMR。
- 与 Vite 生态无缝集成,配置简洁。
- 开箱即用的 TS/JSX 支持。
- Jest 兼容的 API。
- 支持多环境测试 (Node.js, jsdom, happy-dom, edge-runtime)。
- 支持源码内测试 (Source-inlined testing)。
- 提供 Vitest UI 进行可视化测试管理。
4. 安装与初始化
-
说明: 通常作为开发依赖项安装。可以通过 npm, yarn, pnpm 等包管理器进行安装。
-
示例代码:
# 使用 npm 安装
npm install -D vitest
# 使用 yarn 安装
yarn add -D vitest
# 使用 pnpm 安装
pnpm add -D vitest在
package.json
中添加脚本:{
"scripts": {
"test": "vitest", // 运行所有测试
"test:run": "vitest run", // 运行一次,不监听
"test:watch": "vitest watch", // 监听模式 (默认行为)
"coverage": "vitest run --coverage" // 运行并生成覆盖率报告
}
}
二、配置与集成
1. 配置文件(vitest.config.ts/js)
-
说明: Vitest 可以自动读取项目根目录下的
vitest.config.ts
(或.js
,.mjs
,.cjs
) 文件。它也可以与vite.config.ts
合并或独立存在。推荐使用 TypeScript 配置文件以获得类型提示。 -
示例代码 (
vitest.config.ts
):import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
test: {
// Vitest 特有的配置项
globals: true, // 是否启用全局 API (describe, it, expect 等)
environment: "jsdom", // 测试环境:'node', 'jsdom', 'happy-dom', 'edge-runtime'
setupFiles: ["./src/setupTests.ts"], // 测试环境设置文件
coverage: {
// 代码覆盖率配置
provider: "v8", // 或 'istanbul'
reporter: ["text", "json", "html"], // 报告格式
},
},
resolve: {
alias: {
// 与 vite.config.js 中 alias 同步
"@": path.resolve(__dirname, "./src"),
},
},
});
2. 常用配置项
-
说明:
include
/exclude
: 指定包含/排除哪些测试文件,使用 glob 模式。默认包含**/*.{test,spec}.?(c|m)[jt]s?(x)
。environment
: 设置测试运行环境。node
适用于 Node.js 模块测试;jsdom
或happy-dom
模拟浏览器环境,用于 DOM 相关测试;edge-runtime
模拟 Vercel Edge 函数等环境。globals
: 是否将describe
,it
,expect
,vi
等自动注入到全局作用域。推荐设置为false
并显式导入 (import { describe, it, expect, vi } from 'vitest'
) 以获得更好的类型支持和避免全局污染。alias
: 配置路径别名,通常与vite.config.ts
保持一致。setupFiles
: 指定一个或多个在测试环境建立后、测试文件执行前运行的脚本文件。常用于设置全局 Mocks、扩展expect
断言、引入 polyfills 等。coverage
: 配置代码覆盖率报告,包括使用的引擎 (provider
)、报告格式 (reporter
)、包含/排除的文件 (include
/exclude
)、覆盖率阈值 (thresholds
) 等。
-
示例代码 (在
vitest.config.ts
的test
对象内):test: {
include: ['src/**/*.{test,spec}.ts'],
exclude: ['node_modules', 'dist'],
environment: 'happy-dom', // 使用 happy-dom 模拟浏览器环境
globals: false, // 不使用全局变量,需要手动导入
setupFiles: './tests/setup.ts',
alias: { // 这里是 test 作用域内的 alias,也可以配置在顶层 resolve.alias
'~': path.resolve(__dirname, './src/components'),
},
coverage: {
provider: 'istanbul',
enabled: true, // 启用覆盖率
reporter: ['text', 'lcov'],
include: ['src/utils/**', 'src/components/**'],
exclude: ['src/**/*.stories.ts'],
thresholds: { // 设置覆盖率阈值
lines: 80,
functions: 70,
branches: 70,
statements: 80,
},
},
}
3. 与 Vite 配合使用
-
说明: Vitest 的最大优势之一是与 Vite 的无缝集成。它可以直接读取
vite.config.ts
中的大部分配置,如resolve.alias
,plugins
,define
等。 -
示例代码: 如果你已经有了
vite.config.ts
,vitest.config.ts
可以非常简洁,甚至可以合并:// vite.config.ts (同时包含 Vitest 配置)
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import type { UserConfig as VitestUserConfigInterface } from "vitest/config"; // 引入 Vitest 配置类型
import path from "path";
const vitestConfig: VitestUserConfigInterface["test"] = {
// 定义 Vitest 的 test 配置
globals: true,
environment: "jsdom",
};
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
// 直接在 vite config 中添加 test 字段
test: vitestConfig,
});
4. 与 TypeScript 集成
-
说明: Vitest 基于 Vite,而 Vite 原生支持 TypeScript。因此,Vitest 开箱即用地支持使用 TypeScript 编写测试文件 (
.ts
,.tsx
),无需额外配置ts-jest
或类似工具。确保你的tsconfig.json
配置正确即可。 -
示例代码 (
math.test.ts
):import { describe, it, expect } from "vitest";
import { add } from "../src/math"; // 导入 TS 模块
describe("Math functions", () => {
it("should add two numbers correctly", () => {
const result: number = add(1, 2); // 类型检查生效
expect(result).toBe(3);
// expect(result).toBe('3'); // TS 会在编译前提示类型错误 (如果启用了类型检查)
});
});
5. Babel/ESBuild 支持
-
说明: Vitest 默认使用 ESBuild 进行代码转换,速度极快。如果你的项目依赖特定的 Babel 插件或预设,可以通过 Vite 的插件体系(如
@vitejs/plugin-babel
)来集成 Babel。 -
示例代码 (在
vite.config.ts
或vitest.config.ts
中配置 Babel 插件):// vite.config.ts 或 vitest.config.ts
import { defineConfig } from "vite";
import babel from "@vitejs/plugin-babel";
export default defineConfig({
plugins: [
// 添加 Babel 插件,确保它在需要时运行
babel({
babelConfig: {
// 这里放置你的 Babel 配置
plugins: ["@babel/plugin-proposal-decorators"],
},
}),
],
test: {
// ... 其他 Vitest 配置
},
});
6. 环境变量与多环境测试
-
说明: Vitest 继承了 Vite 的环境变量处理方式。可以通过
.env
文件(.env
,.env.test
,.env.local
,.env.test.local
)来加载环境变量。也可以在vite.config.ts
的define
字段中定义全局常量。多环境测试主要通过environment
配置项切换(如 'node' vs 'jsdom')。 -
示例代码:
-
.env.test
:VITE_API_URL=https://test.example.com
-
在测试中使用:
import { describe, it, expect } from "vitest";
describe("Environment variables", () => {
it("should access test environment variables", () => {
// 通过 import.meta.env 访问 (Vite 方式)
expect(import.meta.env.VITE_API_URL).toBe("https://test.example.com");
// process.env 也可以访问,但推荐使用 import.meta.env
// expect(process.env.VITE_API_URL).toBe('https://test.example.com');
});
}); -
vitest.config.ts
中使用define
:defineConfig({
define: {
__APP_VERSION__: JSON.stringify("1.0.0-test"),
},
test: {
/* ... */
},
});测试中直接使用
__APP_VERSION__
。
-
7. CLI 命令与参数
-
说明: Vitest 提供了丰富的命令行接口 (CLI) 来控制测试的执行。
-
常用命令:
vitest
: 启动 Vitest 并进入监听模式。vitest run
: 运行所有测试一次然后退出。vitest watch
: 明确进入监听模式(与直接运行vitest
效果相同)。vitest <pattern>
: 只运行匹配模式的文件或测试名称。例如vitest user
会运行文件名或describe/it
描述中包含 "user" 的测试。
-
常用参数:
--environment <env>
: 覆盖配置文件中的environment
选项。--coverage
: 启用代码覆盖率报告(即使配置中未启用)。--ui
: 启动 Vitest UI,一个可视化的测试界面。--root <path>
: 指定项目根目录。--globals
: 覆盖globals
配置。--watch
: 覆盖run
命令,强制进入监听模式。-t <name>
或--testNamePattern <name>
: 只运行名称匹配的测试用例 (it/test)。-u
或--update
: 更新快照 (Snapshot)。
-
示例:
# 只运行 user.test.ts 文件
vitest user.test.ts
# 运行所有测试并生成覆盖率报告
vitest run --coverage
# 启动 Vitest UI
vitest --ui
# 只运行包含 "login" 的测试用例
vitest -t login
# 更新快照
vitest -u
三、测试用例编写
1. describe、it/test 基本结构
-
说明: Vitest 沿用了 BDD (行为驱动开发) 风格的测试结构。
describe(name, fn)
: 用于将相关的测试用例分组,创建一个测试套件。可以嵌套。it(name, fn)
或test(name, fn)
: 定义一个单独的测试用例。it
和test
功能完全相同,可根据个人偏好选用。
-
示例代码:
import { describe, it, expect } from "vitest";
// 定义一个测试套件
describe("String Utils", () => {
// 定义另一个嵌套的测试套件
describe("capitalize", () => {
// 定义一个测试用例
it("should capitalize the first letter of a string", () => {
const capitalize = (str: string) =>
str.charAt(0).toUpperCase() + str.slice(1);
expect(capitalize("hello")).toBe("Hello");
});
// 另一个测试用例
it("should return empty string if input is empty", () => {
const capitalize = (str: string) =>
str ? str.charAt(0).toUpperCase() + str.slice(1) : "";
expect(capitalize("")).toBe("");
});
});
// 同级别的另一个测试用例
test("toUpperCase function", () => {
// 使用 test 关键字
const toUpperCase = (str: string) => str.toUpperCase();
expect(toUpperCase("world")).toBe("WORLD");
});
});
2. 断言(expect API)
-
说明: Vitest 内置了 Chai 断言库,并通过
expect
API 提供。它还扩展了一些 Jest 风格的匹配器 (Matchers)。断言用于验证代码的行为是否符合预期。 -
常用匹配器:
.toBe(value)
: 严格相等 (===
),用于比较原始类型或对象引用。.toEqual(value)
: 深度相等,用于比较对象或数组的内容。.toBeTruthy()
/.toBeFalsy()
: 判断值是否为真值/假值。.toBeNull()
/.toBeUndefined()
/.toBeDefined()
: 判断是否为null
/undefined
/非undefined
。.toBeInstanceOf(Class)
: 判断实例是否属于某个类。.toContain(item)
/.toContainEqual(item)
: 判断数组或字符串是否包含某个元素(前者严格相等,后者深度相等)。.toHaveLength(number)
: 判断数组或字符串的长度。.toMatch(regexp | string)
: 判断字符串是否匹配正则表达式或包含子串。.toThrow(error?)
: 判断函数是否抛出错误,可指定错误类型或消息。.toMatchSnapshot()
/.toMatchInlineSnapshot()
: 快照测试 (详见下文)。.resolves
: 用于断言 Promise 成功解决后的值。.rejects
: 用于断言 Promise 被拒绝的原因。.toHaveBeenCalled()
/.toHaveBeenCalledTimes(number)
/.toHaveBeenCalledWith(...args)
: 用于 Mock 函数的断言 (详见 Mocking 部分)。
-
示例代码:
import { describe, it, expect } from "vitest";
describe("Assertions", () => {
const user = { name: "Alice", age: 30 };
const getUserPromise = () => Promise.resolve(user);
const throwErrorFunc = () => {
throw new Error("Something went wrong");
};
it("basic assertions", () => {
expect(1 + 1).toBe(2); // 严格相等
expect(user).toEqual({ name: "Alice", age: 30 }); // 深度相等
expect(user.name).toBeTruthy(); // 真值
expect(null).toBeNull(); // null 判断
expect(user).toBeInstanceOf(Object); // 实例判断
});
it("array and string assertions", () => {
const arr = [1, 2, 3];
expect(arr).toContain(2); // 包含元素
expect(arr).toHaveLength(3); // 长度判断
expect("hello world").toMatch(/world/); // 正则匹配
});
it("exception assertion", () => {
expect(throwErrorFunc).toThrow(); // 抛出任何错误
expect(throwErrorFunc).toThrow("Something went wrong"); // 抛出特定消息的错误
expect(throwErrorFunc).toThrow(Error); // 抛出特定类型的错误
});
it("promise assertions", async () => {
await expect(getUserPromise()).resolves.toEqual(user); // Promise 成功
const rejectPromise = () => Promise.reject(new Error("Failed"));
await expect(rejectPromise()).rejects.toThrow("Failed"); // Promise 失败
});
});
3. 钩子函数(beforeAll、beforeEach、afterAll、afterEach)
-
说明: 钩子函数允许在测试套件或每个测试用例执行前后运行设置 (setup) 和清理 (teardown) 代码。
beforeAll(fn, timeout?)
: 在当前describe
块内的所有测试开始前运行一次。beforeEach(fn, timeout?)
: 在当前describe
块内的每个测试开始前运行一次。afterAll(fn, timeout?)
: 在当前describe
块内的所有测试结束后运行一次。afterEach(fn, timeout?)
: 在当前describe
块内的每个测试结束后运行一次。- 钩子函数的作用域是它们所在的
describe
块。顶层的钩子函数作用于整个文件。
-
示例代码:
import {
describe,
it,
expect,
beforeAll,
beforeEach,
afterAll,
afterEach,
} from "vitest";
describe("Hooks Example", () => {
let dbConnection: { data: string[] };
let counter = 0;
// 在所有测试开始前执行一次:连接数据库
beforeAll(() => {
console.log("beforeAll: Connecting to database...");
dbConnection = { data: [] };
});
// 在每个测试开始前执行一次:重置计数器,向数据库添加测试数据
beforeEach(() => {
console.log("beforeEach: Resetting counter and adding test data");
counter = 0;
dbConnection.data.push(`test-${Date.now()}`);
});
// 在每个测试结束后执行一次:清理操作,例如重置 Mocks
afterEach(() => {
console.log("afterEach: Cleaning up after test");
// vi.clearAllMocks(); // 如果使用了 mock
});
// 在所有测试结束后执行一次:断开数据库连接
afterAll(() => {
console.log("afterAll: Disconnecting from database...");
dbConnection = null!; // 模拟断开连接
});
it("test 1: should increment counter", () => {
console.log("Running test 1");
counter++;
expect(counter).toBe(1);
expect(dbConnection.data.length).toBeGreaterThan(0); // 确认 beforeEach 生效
});
it("test 2: counter should be reset by beforeEach", () => {
console.log("Running test 2");
expect(counter).toBe(0); // beforeEach 会重置 counter
counter += 5;
expect(counter).toBe(5);
expect(dbConnection.data.length).toBeGreaterThan(1); // 又添加了一条数据
});
});
4. 跳过与只运行(skip、only、todo)
-
说明: Vitest 提供了控制测试执行流程的修饰符。
.skip
: 附加到describe
或it/test
上,临时跳过该测试套件或测试用例。.only
: 附加到describe
或it/test
上,只运行标记了.only
的测试套件或用例(在当前测试运行中)。如果有多个.only
,它们都会运行。.todo
: 附加到it/test
上,标记一个待完成的测试用例。它会在测试报告中列出,但不会执行。
-
示例代码:
import { describe, it, test } from "vitest";
describe("Execution Control", () => {
// 这个测试套件会被跳过
describe.skip("Skipped Suite", () => {
it("this test will not run", () => {
expect(true).toBe(false); // 因为被跳过,所以不会失败
});
});
// 只运行这个测试套件中的测试 (如果其他地方没有 .only)
describe.only("Focused Suite", () => {
it("this test will run", () => {
expect(1).toBe(1);
});
// 这个测试用例会被跳过
it.skip("this specific test is skipped", () => {
expect(1).toBe(2);
});
});
describe("Normal Suite", () => {
// 这个测试用例因为上面的 .only 而不会运行
it("this test will likely not run unless specified otherwise", () => {
expect(true).toBe(true);
});
// 标记一个待办测试
test.todo("implement the feature and write test", () => {
// 不需要实现,只是一个占位符
});
});
// 如果存在 .only, 这个测试也不会运行
it("another test that might not run", () => {
expect(0).toBe(0);
});
// 如果有多个 .only, 这个也会运行
// it.only('another focused test', () => {
// expect('focus').toBe('focus');
// });
});
5. 并发测试(concurrent)
-
说明: 使用
.concurrent
修饰符可以让同一个文件内的多个测试用例并行执行(在同一个 worker 线程内,利用事件循环)。这对于 I/O 密集型或耗时较长的异步测试可以提升速度。也可以用在describe
上,使其内部所有测试用例默认并发执行。 -
注意: 并发测试可能引入资源竞争或状态污染问题,需要谨慎使用,确保测试之间是独立的。
-
示例代码:
import { describe, it, expect } from "vitest";
const delay = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
// describe.concurrent('Concurrent Suite', () => { // 套件内所有测试并发
describe("Concurrent Tests", () => {
// 这三个测试会尝试并行执行
it.concurrent("test A should pass after 100ms", async () => {
await delay(100);
expect(true).toBe(true);
});
it.concurrent("test B should pass after 50ms", async () => {
await delay(50);
expect(1 + 1).toBe(2);
});
it.concurrent("test C should pass after 150ms", async () => {
await delay(150);
expect("hello").toBe("hello");
});
// 这个测试是串行执行的 (相对于其他 .concurrent 测试)
it("test D runs normally", () => {
expect(0).toBe(0);
});
});
6. 超时与异步测试
-
说明:
- 异步测试: Vitest 对
async/await
和返回 Promise 的测试有原生支持。只需将测试函数标记为async
或直接返回一个 Promise。Vitest 会等待 Promise 解决或拒绝。 - 超时: 每个测试默认有 5000ms (5 秒) 的超时时间。可以通过以下方式修改:
- CLI 参数:
vitest --testTimeout=10000
- 配置文件:
test.testTimeout
- 在
describe
或it/test
的第二个参数(一个对象)中设置timeout
属性。 - 在
it
/test
/钩子函数的第三个参数中直接传递超时时间(数字)。
- CLI 参数:
- 异步测试: Vitest 对
-
示例代码:
import { describe, it, expect } from "vitest";
const fetchData = (shouldSucceed: boolean) =>
new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldSucceed) {
resolve({ data: "success" });
} else {
reject(new Error("fetch failed"));
}
}, 100); // 模拟网络请求
});
describe("Async and Timeout", () => {
// 使用 async/await
it("should fetch data successfully using async/await", async () => {
const result = await fetchData(true);
expect(result).toEqual({ data: "success" });
});
// 直接返回 Promise
it("should handle rejected promise", () => {
// Vitest 会自动处理 Promise 的 reject
return expect(fetchData(false)).rejects.toThrow("fetch failed");
});
// 设置单个测试的超时时间为 200ms
it("should complete within 200ms", async () => {
await fetchData(true);
}, 200); // 第三个参数直接设置超时时间
// 使用对象配置超时时间
it(
"should also complete within 200ms (object config)",
{ timeout: 200 },
async () => {
await fetchData(true);
}
);
// 这个测试会因为超时而失败 (默认 5s,这里模拟耗时 6s)
// it('should fail due to timeout', async () => {
// await new Promise(resolve => setTimeout(resolve, 6000));
// }, 5000); // 如果不设置第三个参数,则使用默认或配置的超时
});
7. 参数化测试(each)
-
说明:
.each
允许你使用不同的输入数据多次运行同一个测试用例,减少代码重复。它有两种主要形式:- 数组形式:
it.each(table)(name, fn)
,table
是一个包含测试参数的数组(通常是数组的数组或对象的数组)。 - 模板字符串形式:
it.each
\``
...(name, fn)
,使用表格形式的模板字符串定义参数。
- 数组形式:
-
示例代码:
import { describe, it, expect } from "vitest";
import { add } from "../src/math"; // 假设有 add 函数
describe("Parameterized Tests with .each", () => {
// 1. 数组形式 (数组的数组)
describe("Array of Arrays", () => {
const testCases: [number, number, number][] = [
[1, 2, 3],
[0, 0, 0],
[-1, 1, 0],
[10, -5, 5],
];
// %s 会被参数替换 (或使用 %i 索引, %j JSON)
// testCases 中的每个子数组对应一次测试调用
it.each(testCases)("add(%s, %s) should return %s", (a, b, expected) => {
expect(add(a, b)).toBe(expected);
});
});
// 2. 数组形式 (对象的数组)
describe("Array of Objects", () => {
const testCases = [
{ a: 1, b: 2, expected: 3, description: "positive numbers" },
{ a: -5, b: -3, expected: -8, description: "negative numbers" },
{ a: 100, b: 0, expected: 100, description: "with zero" },
];
// 使用对象的属性名进行插值
it.each(testCases)(
"should handle $description: add($a, $b) => $expected",
({ a, b, expected }) => {
expect(add(a, b)).toBe(expected);
}
);
});
// 3. 模板字符串形式
describe("Template Literal", () => {
// 表格标题行定义变量名,后续行是参数值
it.each`
a | b | expected | description
${1} | ${2} | ${3} | ${"positive"}
${-1} | ${-1} | ${-2} | ${"negative"}
${0} | ${5} | ${5} | ${"zero"}
`(
"Template: $description - add($a, $b) should be $expected",
({ a, b, expected }) => {
expect(add(a, b)).toBe(expected);
}
);
});
});
四、Mock 能力 (vi
)
Vitest 提供了一个强大的内置 Mocking API,通过全局(如果启用 globals: true
)或导入的 vi
对象来访问。它的 API 设计同样与 Jest 非常相似。
1. 自动 Mock 与手动 Mock
-
说明:
- 自动 Mock (
vi.mock
): Vitest 可以自动模拟整个模块。当你vi.mock('path/to/module')
时,所有从该模块导出的函数都会被替换成行为类似vi.fn()
的 Mock 函数,并且它们不会执行原始实现。这对于模拟第三方库或项目内部模块非常有用。 - 手动 Mock: 你可以提供一个工厂函数作为
vi.mock
的第二个参数,来精确控制 Mock 的实现。这允许你为模块的导出提供自定义的 Mock 函数或值。手动 Mock 通常放在__mocks__
目录下(与 Jest 类似)或者直接在vi.mock
的工厂函数中定义。
- 自动 Mock (
-
示例代码:
// src/userService.ts
export const getUserById = (id: number) => {
console.log("Fetching user from DB..."); // 原始实现会打印日志
return { id, name: `User ${id}` };
};
export const defaultUser = { id: 0, name: "Guest" };
// test/userService.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
// import * as userService from '../src/userService'; // 在 mock 之后导入
// 1. 自动 Mock
vi.mock("../src/userService"); // 告诉 Vitest 自动模拟这个模块
// 需要在 vi.mock 之后导入被模拟的模块
import * as userService from "../src/userService";
// 或者 import { getUserById, defaultUser } from '../src/userService';
describe("User Service Mocking", () => {
beforeEach(() => {
// 在每个测试前重置 mock 状态 (调用次数等)
vi.clearAllMocks();
// 如果需要重置 mock 的实现到初始状态(自动 mock 或工厂函数定义的)
// vi.resetAllMocks();
});
it("should automatically mock getUserById", () => {
// 调用被自动 mock 的函数
const user = userService.getUserById(1);
// 它现在是一个 mock 函数,不会执行原始实现(不会打印日志)
expect(userService.getUserById).toHaveBeenCalled();
expect(userService.getUserById).toHaveBeenCalledWith(1);
// 自动 mock 的函数默认返回 undefined
expect(user).toBeUndefined();
// 非函数导出(如 defaultUser)不会被自动 mock,保持原样
// 注意:实际行为可能取决于 Vitest 版本和配置,有时非函数也会被处理
// 推荐在需要时显式地 mock 或使用工厂函数
// expect(userService.defaultUser).toEqual({ id: 0, name: 'Guest' });
});
// 2. 手动 Mock (使用工厂函数)
// 在另一个测试文件中或使用 describe 块隔离
// vi.mock('../src/anotherService', () => ({
// fetchData: vi.fn(() => Promise.resolve('mocked data')),
// CONFIG: { timeout: 500 } // 可以 mock 非函数导出
// }));
// import { fetchData, CONFIG } from '../src/anotherService';
// it('should use manual mock implementation', async () => {
// const data = await fetchData();
// expect(fetchData).toHaveBeenCalled();
// expect(data).toBe('mocked data');
// expect(CONFIG.timeout).toBe(500);
// });
// 3. __mocks__ 文件夹 (手动 Mock 的另一种方式)
// 在项目根目录或 src 下创建 `__mocks__/userService.ts`
// // __mocks__/userService.ts
// export const getUserById = vi.fn((id: number) => ({ id, name: `Mock User ${id}` }));
// export const defaultUser = { id: -1, name: 'Mock Guest' };
// 然后在测试文件中只需 vi.mock('../src/userService');
// Vitest 会自动查找并使用 __mocks__ 目录下的实现
});
2. mock
, unmock
, doMock
, doUnmock
, importActual
, importMock
-
说明:
vi.mock(path, factory?)
: 如上所述,用于模拟模块。注意:vi.mock
的调用会被提升 (hoisted) 到文件的顶部执行,无论它写在哪里。这意味着你不能在测试逻辑中间根据条件vi.mock
。vi.unmock(path)
: 取消对指定模块的模拟,恢复使用原始实现。同样会被提升。vi.doMock(path, factory?)
: 功能与vi.mock
类似,但它不会被提升。这意味着你可以在beforeEach
或it
内部根据需要动态地应用 Mock。这在需要根据不同测试用例使用不同 Mock 实现时非常有用。vi.doUnmock(path)
: 功能与vi.unmock
类似,但不会被提升。用于动态取消 Mock。vi.importActual(path)
: 异步导入模块的原始实现,即使该模块已经被vi.mock
模拟了。这在你需要在 Mock 工厂函数中引用原始模块的部分功能时很有用。vi.importMock(path)
: 异步导入模块的模拟版本。通常在你需要在vi.mock
工厂函数之外获取到 Mock 对象时使用。
-
示例代码:
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
describe("Dynamic Mocking and Importing", () => {
// 使用 doMock 在 beforeEach 中动态模拟
beforeEach(async () => {
// 模拟 mathUtils,但在 test 2 中会取消模拟
await vi.doMock("../src/mathUtils", () => ({
add: vi.fn((a: number, b: number) => a + b + 100), // 错误的加法
subtract: vi.fn((a: number, b: number) => a - b),
}));
});
afterEach(() => {
// 清理动态 mock
vi.doUnmock("../src/mathUtils");
// vi.resetModules(); // 重置模块缓存,确保下次 import 拿到最新状态
});
it("test 1: should use the dynamically mocked add function", async () => {
// 导入的是 mock 版本
const mathUtils = await vi.importMock("../src/mathUtils");
expect(mathUtils.add(1, 2)).toBe(103); // 1 + 2 + 100
expect(mathUtils.add).toHaveBeenCalledWith(1, 2);
});
it("test 2: should use the original add function after unmocking", async () => {
// 取消模拟
vi.doUnmock("../src/mathUtils");
vi.resetModules(); // 需要重置模块缓存才能拿到原始模块
// 导入的是原始版本
const mathUtils = await import("../src/mathUtils"); // 使用原生 import
// 或者 const mathUtils = await vi.importActual('../src/mathUtils');
expect(mathUtils.add(1, 2)).toBe(3); // 原始实现
// expect(mathUtils.add).not.toHaveBeenCalled(); // 原始函数不是 mock 函数
});
// 在 mock 工厂中使用 importActual
it("test 3: mock factory using original implementation", async () => {
await vi.doMock("../src/complexService", async () => {
const original = await vi.importActual<
typeof import("../src/complexService")
>("../src/complexService");
return {
...original, // 保留原始模块的其他导出
processData: vi.fn(() => "mocked process result"), // 只 mock processData
// setup: original.setup // 可以直接引用原始函数
};
});
const complexService = await vi.importMock("../src/complexService");
expect(complexService.processData()).toBe("mocked process result");
// expect(complexService.anotherFunction).toBe(original.anotherFunction); // 假设有其他函数
});
});
3. spyOn
与模拟函数 (vi.fn
)
-
说明:
vi.fn(implementation?)
: 创建一个空的 Mock 函数,可以追踪调用、参数、返回值和实例。可以提供一个可选的实现函数。vi.spyOn(object, methodName)
: 监视(spy)一个对象上的现有方法。它会包装原始方法,允许你追踪调用,同时默认情况下仍然执行原始实现。你也可以使用.mockImplementation()
或.mockReturnValue()
等方法替换其实现。spyOn
创建的 spy 需要手动恢复,通常在afterEach
中使用vi.restoreAllMocks()
或spy.mockRestore()
。
-
示例代码:
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const calculator = {
add: (a: number, b: number) => a + b,
subtract(a: number, b: number) {
// 使用方法语法
return a - b;
},
};
describe("Spying and Mock Functions", () => {
beforeEach(() => {
// 在每个测试前重置所有 mock/spy 状态
vi.clearAllMocks();
});
afterEach(() => {
// 恢复所有被 spyOn 改变的方法
vi.restoreAllMocks();
});
it("should create a basic mock function", () => {
const mockCallback = vi.fn();
[1, 2].forEach(mockCallback);
expect(mockCallback).toHaveBeenCalled();
expect(mockCallback).toHaveBeenCalledTimes(2);
expect(mockCallback).toHaveBeenCalledWith(1, 0, [1, 2]); // 回调参数
expect(mockCallback).toHaveBeenNthCalledWith(2, 2, 1, [1, 2]);
});
it("should create a mock function with implementation", () => {
const mockAdd = vi.fn((a, b) => a + b + 10); // 带实现的 mock
expect(mockAdd(3, 4)).toBe(17);
expect(mockAdd).toHaveReturnedWith(17);
});
it("should spy on an object method and track calls", () => {
// 监视 calculator.add 方法
const addSpy = vi.spyOn(calculator, "add");
const result = calculator.add(5, 3); // 调用原始方法
expect(result).toBe(8); // 原始实现被执行
expect(addSpy).toHaveBeenCalled();
expect(addSpy).toHaveBeenCalledWith(5, 3);
expect(addSpy).toHaveReturnedWith(8);
// addSpy.mockRestore(); // 单独恢复 spy
});
it("should spy on and mock the implementation of a method", () => {
const subtractSpy = vi.spyOn(calculator, "subtract");
// 替换实现
subtractSpy.mockImplementation((a, b) => a - b + 100);
const result = calculator.subtract(10, 5); // 调用的是 mock 实现
expect(result).toBe(105); // 10 - 5 + 100
expect(subtractSpy).toHaveBeenCalledWith(10, 5);
expect(subtractSpy).toHaveReturnedWith(105);
// 恢复原始实现
subtractSpy.mockRestore();
expect(calculator.subtract(10, 5)).toBe(5); // 恢复后执行原始实现
});
it("should spy on and mock the return value", () => {
const addSpy = vi.spyOn(calculator, "add");
addSpy.mockReturnValue(999); // 固定返回值
expect(calculator.add(1, 1)).toBe(999); // 返回固定值
expect(addSpy).toHaveBeenCalledWith(1, 1);
});
});
4. 定时器与日期 Mock
-
说明: Vitest 提供了控制
setTimeout
,setInterval
,Date
等时间相关 API 的能力,使得测试依赖时间的代码变得可预测。vi.useFakeTimers()
: 启用定时器 Mock。所有定时器调用将被 Vitest 控制。vi.useRealTimers()
: 禁用定时器 Mock,恢复使用真实定时器。vi.advanceTimersByTime(ms)
: 快进时间,并执行在此期间到期的所有定时器。vi.advanceTimersToNextTimer()
: 快进到下一个定时器即将执行的时间点,并执行它。vi.runAllTimers()
: 执行当前队列中所有待处理的宏任务(setTimeout
,setInterval
)定时器。vi.runAllTicks()
: 执行当前队列中所有待处理的微任务(Promise.then
)。vi.setSystemTime(date | timestamp)
: 设置当前的系统时间 (Date.now()
,new Date()
)。vi.getRealSystemTime()
/vi.getTimerCount()
: 获取真实时间 / 当前待处理的定时器数量。
-
注意: 通常在
beforeEach
中vi.useFakeTimers()
,在afterEach
中vi.useRealTimers()
。 -
示例代码:
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
function runAfterDelay(callback: () => void, delay: number) {
setTimeout(callback, delay);
}
function getCurrentYear() {
return new Date().getFullYear();
}
describe("Timer and Date Mocking", () => {
beforeEach(() => {
vi.useFakeTimers(); // 启用 Mock 定时器和 Date
});
afterEach(() => {
vi.useRealTimers(); // 恢复真实定时器和 Date
});
it("should execute setTimeout callback after advancing time", () => {
const myCallback = vi.fn();
runAfterDelay(myCallback, 1000); // 设置 1 秒后执行的回调
expect(myCallback).not.toHaveBeenCalled(); // 时间未到,回调未执行
vi.advanceTimersByTime(500); // 快进 500ms
expect(myCallback).not.toHaveBeenCalled();
vi.advanceTimersByTime(500); // 再快进 500ms (总共 1000ms)
expect(myCallback).toHaveBeenCalledTimes(1); // 回调执行
});
it("should run all timers", () => {
const cb1 = vi.fn();
const cb2 = vi.fn();
setTimeout(cb1, 100);
setTimeout(cb2, 200);
vi.runAllTimers(); // 执行所有待处理定时器
expect(cb1).toHaveBeenCalled();
expect(cb2).toHaveBeenCalled();
});
it("should mock the system date", () => {
const specificDate = new Date(2023, 5, 15); // 2023年6月15日 (月份从0开始)
vi.setSystemTime(specificDate);
expect(getCurrentYear()).toBe(2023);
expect(new Date().getDate()).toBe(15);
expect(Date.now()).toBe(specificDate.getTime());
// 快进时间也会影响 Date.now()
vi.advanceTimersByTime(24 * 60 * 60 * 1000); // 快进一天
expect(new Date().getDate()).toBe(16);
});
});
5. 网络请求与全局对象 Mock
-
说明:
- 网络请求: Vitest 本身不内置网络请求 Mock 库,但推荐使用如
msw
(Mock Service Worker) 或nock
(Node.js HTTP mocking) 等库。你可以将这些库的设置集成到 Vitest 的setupFiles
中。 - 全局对象: 对于需要 Mock 的全局对象(如
window
,document
- 在 'jsdom'/'happy-dom' 环境下,或者自定义的全局变量),可以使用vi.spyOn
配合getter
来模拟其属性,或者直接在测试文件的顶部(或setupFiles
)修改全局对象。
- 网络请求: Vitest 本身不内置网络请求 Mock 库,但推荐使用如
-
示例代码:
// setupTests.ts (在 vitest.config.ts 中配置 setupFiles: ['./setupTests.ts'])
import { vi } from "vitest";
import { server } from "./mocks/server"; // 假设使用 msw
// 启动 msw 服务器
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
// 每个测试后重置 msw 处理程序
afterEach(() => server.resetHandlers());
// 所有测试结束后关闭服务器
afterAll(() => server.close());
// 模拟全局 fetch (如果未使用 msw 等拦截库)
// const mockFetch = vi.fn(() => Promise.resolve({
// ok: true,
// json: () => Promise.resolve({ data: 'mocked fetch response' })
// }));
// vi.stubGlobal('fetch', mockFetch); // 使用 stubGlobal 模拟全局变量
// test/api.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fetchUserData } from "../src/apiService"; // 假设这个函数内部使用了 fetch
// 模拟 window.location
describe("Global Object Mocking", () => {
const originalLocation = window.location;
beforeEach(() => {
// 需要确保能够修改 window.location
// Vitest 的 jsdom/happy-dom 环境通常允许这样做
// 使用 Object.defineProperty 来模拟只读属性
Object.defineProperty(window, "location", {
writable: true, // 使其可写,以便我们可以更改它
value: {
...originalLocation, // 保留原始 location 的其他部分
href: "http://test.com/path",
pathname: "/path",
search: "?query=123",
assign: vi.fn(), // mock assign 方法
},
});
});
afterEach(() => {
// 恢复原始 location
Object.defineProperty(window, "location", {
writable: true,
value: originalLocation,
});
vi.restoreAllMocks(); // 清理 vi.fn()
});
it("should read the mocked location.pathname", () => {
expect(window.location.pathname).toBe("/path");
});
it("should track calls to location.assign", () => {
window.location.assign("http://new-url.com");
expect(window.location.assign).toHaveBeenCalledWith("http://new-url.com");
});
});
// 测试网络请求 (假设已通过 setupTests.ts 配置了 msw)
describe("API Service with MSW", () => {
it("fetchUserData should return mocked data", async () => {
// msw 会拦截实际的 fetch 调用并返回 mock 响应
const userData = await fetchUserData(1);
expect(userData).toEqual({ id: 1, name: "Mocked User via MSW" });
});
});
五、测试覆盖率
1. 覆盖率统计(coverage)
-
说明: Vitest 支持生成代码覆盖率报告,以评估测试对代码库的覆盖程度。它可以通过 CLI 参数或配置文件启用。
-
启用:
-
CLI:
vitest run --coverage
-
配置文件 (
vitest.config.ts
):import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
enabled: true, // 启用覆盖率
// provider: 'v8' (默认) 或 'istanbul'
},
},
});
-
-
Provider: Vitest 支持两种覆盖率引擎:
v8
: (默认) Node.js 内置的覆盖率引擎,速度快,但可能在某些边缘情况下(如 TS 装饰器、复杂类型转换)不如 Istanbul 精确。需要 Node.js v14.18+。istanbul
: 成熟的、广泛使用的覆盖率工具链,兼容性好,功能更全面,但通常比 v8 慢。
2. 配置覆盖率报告格式(text、html、lcov 等)
- 说明: 可以配置生成多种格式的覆盖率报告。
- 配置 (
vitest.config.ts
的test.coverage
对象内):coverage: {
enabled: true,
provider: 'istanbul', // 或 'v8'
// 配置报告器
reporter: [
// 输出到控制台的文本摘要
'text',
// 生成详细的 HTML 报告,可在浏览器中查看
'html',
// 生成 lcov.info 文件,常用于 CI 服务 (如 Coveralls, Codecov)
'lcov',
// 生成 cobertura 格式的 XML 报告,常用于 Jenkins 等 CI
'cobertura',
// 生成 JSON 格式的报告
'json',
// 生成 JSON 摘要
'json-summary',
],
// 配置各种报告器的输出目录
reportsDirectory: './coverage', // 默认是 ./coverage
}
3. 排除文件/目录
- 说明: 通常需要从覆盖率报告中排除测试文件、配置文件、类型定义文件、第三方库或某些特定目录。
- 配置 (
vitest.config.ts
的test.coverage
对象内):coverage: {
enabled: true,
provider: 'v8',
// 使用 glob 模式指定包含哪些文件进行覆盖率统计
include: ['src/**/*.{js,ts,jsx,tsx}'],
// 使用 glob 模式指定排除哪些文件
exclude: [
'node_modules/**',
'dist/**',
'coverage/**',
'*.config.{js,ts,cjs,mjs}', // 排除配置文件
'**/*.d.ts', // 排除类型定义文件
'src/main.ts', // 排除入口文件 (如果不需要)
'src/types/**', // 排除类型目录
'**/{tests,test,__tests__}/**', // 排除测试文件目录
'**/*.{test,spec}.{js,ts,jsx,tsx}', // 排除测试文件本身
'src/mocks/**', // 排除 mock 文件目录
'src/**/*.stories.{js,ts,jsx,tsx}', // 排除 Storybook 文件
],
// 如果 all 设置为 true, 未被测试覆盖的文件也会包含在报告中 (显示 0% 覆盖率)
all: true,
// 清理上次运行的文件 (推荐开启)
clean: true,
}
4. 与 CI 集成
- 说明: 将覆盖率报告集成到持续集成 (CI) 流程中,可以在每次代码提交或合并时自动检查覆盖率。通常需要将覆盖率报告(如
lcov.info
)上传到第三方服务(如 Codecov, Coveralls)或 CI 平台自身的功能。 - 步骤:
- 在 CI 脚本中运行覆盖率: 修改 CI 配置文件(如
.github/workflows/ci.yml
,.gitlab-ci.yml
)中的测试步骤,添加--coverage
参数。# GitHub Actions 示例
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: "18"
cache: "pnpm" # 或 npm/yarn
- run: pnpm install # 或 npm ci / yarn install
- name: Run tests and collect coverage
run: pnpm test --coverage # 或 npm run test -- --coverage
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v3
with:
# token: ${{ secrets.CODECOV_TOKEN }} # 如果是私有仓库需要 token
files: ./coverage/lcov.info # 指定 lcov 文件路径
fail_ci_if_error: true - 配置覆盖率报告格式: 确保
vitest.config.ts
中配置了 CI 服务所需的报告格式(通常是lcov
)。 - 设置覆盖率阈值 (可选): 可以在
vitest.config.ts
的coverage.thresholds
中设置覆盖率阈值(行、分支、函数、语句)。如果未达到阈值,Vitest 会以非零状态码退出,从而使 CI 构建失败。coverage: {
// ... 其他配置
thresholds: {
lines: 80,
functions: 70,
branches: 70,
statements: 80,
// 也可以为全局或单个文件设置阈值
// global: { ... },
// './src/utils/critical.ts': { lines: 95 }
},
// 如果阈值检查失败是否以错误退出 (默认为 false)
// 在 CI 中通常需要设置为 true
// failUnderThresholds: true, // Vitest v1+ 可能不再需要这个,检查失败默认会退出
} - 配置 CI 服务: 根据所选的覆盖率服务(Codecov, Coveralls 等)或 CI 平台的要求,可能需要在项目中添加配置文件(如
codecov.yml
)或在 CI 服务网站上进行设置。
- 在 CI 脚本中运行覆盖率: 修改 CI 配置文件(如
六、快照测试
1. 快照基础(toMatchSnapshot
、toMatchInlineSnapshot
)
-
说明: 快照测试是一种用于验证 UI 组件、数据结构或函数输出是否随时间保持不变的技术。
- 当你第一次运行
expect(value).toMatchSnapshot()
时,Vitest 会将value
的序列化结果存储在一个.snap
文件中(通常在__snapshots__
目录下)。 - 之后每次运行测试,Vitest 会将当前的
value
序列化后与存储的快照进行比较。如果匹配,测试通过;如果不匹配,测试失败,提示你需要更新快照或修复代码。 toMatchInlineSnapshot()
功能类似,但它会将快照内容直接写入测试文件 (.test.ts
) 中expect
语句的参数里,而不是单独的.snap
文件。这对于小型、易读的快照很方便。
- 当你第一次运行
-
适用场景:
- 测试 React/Vue/Svelte 组件的渲染输出。
- 验证复杂的对象或数据结构。
- 确保 API 响应格式的稳定性。
-
示例代码:
import { describe, it, expect } from "vitest";
function getUserProfile(userId: number) {
// 模拟返回一个复杂的对象
return {
id: userId,
name: `User ${userId}`,
email: `user${userId}@example.com`,
address: {
street: `${userId} Main St`,
city: "Anytown",
zip: `${userId}0000`,
},
createdAt: new Date("2023-01-01T10:00:00.000Z"), // 注意 Date 对象
roles: ["user", userId === 1 ? "admin" : undefined].filter(Boolean), // 动态角色
settings: {
theme: "dark",
notifications: {
email: true,
sms: false,
},
},
};
}
describe("Snapshot Testing", () => {
it("should match the user profile snapshot", () => {
const userProfile = getUserProfile(1);
// 第一次运行时会生成 __snapshots__/myTest.test.ts.snap 文件
// 后续运行会与 .snap 文件内容比较
expect(userProfile).toMatchSnapshot();
});
it("should match the user profile snapshot for another user", () => {
const userProfile = getUserProfile(2);
// 同一个 describe 块但不同的 it, 会在同一个 .snap 文件中生成独立的快照
expect(userProfile).toMatchSnapshot();
});
it("should match the theme setting inline snapshot", () => {
const settings = getUserProfile(3).settings;
// 第一次运行时,Vitest 会自动填充快照内容到括号里
// 后续运行会与括号里的内容比较
expect(settings.theme).toMatchInlineSnapshot('"dark"'); // 假设第一次运行后填充了 "dark"
});
it("should match the notification settings inline snapshot", () => {
const notifications = getUserProfile(4).settings.notifications;
// 对于对象,内联快照会格式化
expect(notifications).toMatchInlineSnapshot(`
{
"email": true,
"sms": false,
}
`);
});
// 使用属性匹配器进行部分快照
it("should match parts of the profile using property matchers", () => {
const userProfile = getUserProfile(5);
expect(userProfile).toMatchSnapshot({
// 对于动态变化或不关心的字段,可以使用 expect.any() 或具体类型
id: expect.any(Number), // id 只要是数字就行
createdAt: expect.any(Date), // createdAt 只要是 Date 对象就行
address: {
zip: expect.stringMatching(/^\d{5}$/), // zip 必须是5位数字
},
});
});
});
// 生成的 __snapshots__/myTest.test.ts.snap 文件可能看起来像这样:
/*
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Snapshot Testing > should match the user profile snapshot 1`] = `
{
"address": {
"city": "Anytown",
"street": "1 Main St",
"zip": "10000",
},
"createdAt": 2023-01-01T10:00:00.000Z,
"email": "user1@example.com",
"id": 1,
"name": "User 1",
"roles": [
"user",
"admin",
],
"settings": {
"notifications": {
"email": true,
"sms": false,
},
"theme": "dark",
},
}
`;
exports[`Snapshot Testing > should match the user profile snapshot for another user 1`] = `
{
"address": {
// ... user 2 data ...
},
// ... rest of user 2 profile ...
}
`;
// ... 其他快照 ...
*/
2. 快照更新与管理
- 说明: 当代码变更导致快照不再匹配时(无论是预期的变更还是 Bug),测试会失败。此时需要:
- 审查差异: Vitest 会在控制台清晰地展示新旧快照之间的差异。仔细检查这些差异是否符合预期。
- 更新快照: 如果差异是预期的(例如,你修改了组件的输出或数据结构),运行
vitest -u
或vitest --update
(或者在监听模式下按u
键)。Vitest 会用新的结果覆盖.snap
文件或更新内联快照。 - 修复代码: 如果差异是意外的(引入了 Bug),则需要修复代码,然后重新运行测试,直到快照匹配。
- 管理:
- 快照文件 (
.snap
) 应与测试文件一起提交到版本控制系统(如 Git)。 - 定期审查快照文件,特别是大型快照,确保它们仍然有意义且易于理解。
- 对于容易频繁变动或包含大量无关细节的快照,考虑使用更具体的断言或属性匹配器 (
expect.any
,expect.stringContaining
等) 来代替完整的快照。
- 快照文件 (
3. 快照测试的最佳实践
- 保持快照简洁: 快照应尽可能小而聚焦。避免对包含大量无关数据或易变数据(如随机 ID、时间戳)的整个大型对象进行快照。使用属性匹配器 (
expect.any()
,expect.stringMatching()
) 或只对对象的关键部分进行快照。 - 明确测试意图: 快照本身不能完全替代描述性的测试。确保
it
/test
的名称清晰地说明了快照要验证的内容。 - 代码审查: 在代码审查中应仔细检查快照文件的变更。快照更新不应被随意接受,需要确认变更是合理且预期的。
- 避免过度使用: 不要滥用快照测试所有东西。对于简单的逻辑或可以通过常规断言轻松验证的行为,优先使用常规断言,它们通常更易读、更稳定。快照最适合用于验证结构复杂、序列化输出稳定的场景。
- 结合其他测试: 快照测试主要验证“输出是否与上次相同”,但不保证输出的正确性。应结合其他单元测试来验证功能的逻辑正确性。
- 内联快照适用性: 内联快照 (
toMatchInlineSnapshot
) 适用于非常小、易读的快照(如短字符串、小对象)。对于大型或复杂的结构,独立的.snap
文件通常更易于管理和审查。
七、测试环境
Vitest 允许你在不同的环境中运行测试,以模拟代码最终运行的场景。
1. jsdom
环境
-
说明:
jsdom
是一个在 Node.js 中模拟浏览器环境(包括 DOM、BOM、window
、document
等 API)的库。这是进行前端组件单元测试或集成测试时最常用的环境,因为它允许你渲染组件、操作 DOM 和测试与浏览器 API 相关的逻辑。 -
配置: 在
vitest.config.ts
中设置environment
为'jsdom'
。 -
示例代码:
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom", // 设置测试环境为 jsdom
// ... 其他配置
},
});
// test/dom.test.ts
import { describe, it, expect } from "vitest";
describe("DOM Manipulation in jsdom environment", () => {
it("should create and manipulate a DOM element", () => {
// 在 jsdom 环境下可以直接使用 document
const div = document.createElement("div");
div.textContent = "Hello Vitest";
document.body.appendChild(div);
const element = document.querySelector("div");
expect(element).not.toBeNull();
expect(element?.textContent).toBe("Hello Vitest");
// 清理 DOM (虽然 jsdom 会在每个测试后重置,但手动清理是好习惯)
document.body.removeChild(div);
});
});
2. node
环境
-
说明: 这是 Vitest 的默认环境。测试代码直接在 Node.js 运行时中执行。适用于测试与 DOM 无关的逻辑,例如:
- Node.js 后端代码
- 纯 JavaScript/TypeScript 工具函数库
- 算法或数据结构
- 不涉及浏览器特定 API 的业务逻辑
这个环境通常比
jsdom
或happy-dom
更快,因为它不需要模拟浏览器环境。
-
配置: 在
vitest.config.ts
中设置environment
为'node'
或不设置 (使用默认值)。 -
示例代码:
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node", // 明确设置为 node 环境
},
});
// test/math.test.ts
import { describe, it, expect } from "vitest";
import { sum } from "../src/utils/math"; // 假设有一个纯工具函数
describe("Math Utils in node environment", () => {
it("should sum two numbers correctly", () => {
// 不需要 DOM API
expect(sum(2, 3)).toBe(5);
});
});
3. happy-dom
支持
-
说明:
happy-dom
是jsdom
的另一个替代品,旨在提供一个更快、内存占用更低的浏览器环境模拟。它可能没有jsdom
那么完整或严格遵循 Web 标准,但在许多场景下足够使用,并且可以显著提升测试速度。 -
配置:
- 安装
happy-dom
:npm install -D happy-dom
或pnpm add -D happy-dom
或yarn add -D happy-dom
。 - 在
vitest.config.ts
中设置environment
为'happy-dom'
。
- 安装
-
示例代码:
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "happy-dom", // 设置测试环境为 happy-dom
},
});
// test/component.happy.test.ts (测试代码与 jsdom 类似)
import { describe, it, expect } from "vitest";
describe("Component in happy-dom", () => {
it("should render correctly", () => {
document.body.innerHTML = "<button>Click Me</button>";
const button = document.querySelector("button");
expect(button?.textContent).toBe("Click Me");
document.body.innerHTML = ""; // 清理
});
});
4. 自定义环境
-
说明: 对于特殊需求(例如测试 Service Workers、Cloudflare Workers、或其他非标准 JavaScript 运行时),Vitest 允许你创建和使用自定义环境。这需要实现 Vitest 提供的
Environment
接口。这是一个高级功能。 -
配置: 在
vitest.config.ts
中设置environment
为你自定义环境包的名称或路径。 -
示例代码:
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
// environment: 'vitest-environment-my-custom-env', // 指向npm包
// environment: './path/to/my-custom-environment.ts' // 指向本地文件
// ...
},
});
// my-custom-environment.ts (极其简化的示意)
// import type { Environment } from 'vitest';
// export default <Environment>{
// name: 'my-custom-environment',
// async setup() {
// console.log('Setting up custom environment...');
// // 设置全局变量、模拟 API 等
// globalThis.myCustomGlobal = {};
// return {
// async teardown() {
// console.log('Tearing down custom environment...');
// // 清理资源
// },
// };
// },
// // 可能还需要实现 handle Vite specific requests 等方法
// };注意: 实现自定义环境比较复杂,具体请参考 Vitest 官方文档。
5. DOM 测试与 UI 测试
-
说明: 这是指在模拟 DOM 环境 (
jsdom
或happy-dom
) 中进行的测试,主要目的是验证代码是否能正确地与 DOM 交互或 UI 组件是否按预期渲染和响应。Vitest 提供了运行这些测试的基础设施(环境和测试运行器),但具体的 DOM 查询、事件触发和断言通常需要借助专门的库,最常用的是 Testing Library。 -
示例代码 (概念性,结合 Testing Library 会更具体):
// test/ui.test.ts (假设在 jsdom 环境)
import { describe, it, expect } from "vitest";
describe("Basic UI interaction", () => {
it("should update text content on button click", () => {
document.body.innerHTML = `
<p>Initial Text</p>
<button>Change Text</button>
`;
const paragraph = document.querySelector("p");
const button = document.querySelector("button");
expect(paragraph?.textContent).toBe("Initial Text");
// 模拟点击事件
button?.addEventListener("click", () => {
if (paragraph) {
paragraph.textContent = "Text Changed!";
}
});
button?.click(); // 触发点击
expect(paragraph?.textContent).toBe("Text Changed!");
document.body.innerHTML = ""; // 清理
});
});
八、与前端框架集成
Vitest 因为与 Vite 的紧密集成,能够非常方便地测试使用 Vite 构建的各种前端框架项目。
1. Vue 组件测试 (@testing-library/vue
)
-
说明: 使用
@testing-library/vue
可以在 Vitest 环境中轻松地测试 Vue 组件。它鼓励你像用户一样与组件交互(查找元素、触发事件)而不是依赖组件内部状态。需要jsdom
或happy-dom
环境。确保你的 Vite 配置包含了@vitejs/plugin-vue
。 -
安装:
npm install -D @testing-library/vue @vue/test-utils
(Vue Test Utils 是 Testing Library for Vue 的 peer dependency) -
示例代码:
// src/components/Counter.vue
<template>
<div>
<p>Count: {{ count }}</p>
<button @click="increment">Increment</button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const count = ref(0);
const increment = () => count.value++;
</script>
// test/Counter.test.ts (确保环境是 jsdom/happy-dom)
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/vue';
import Counter from '../src/components/Counter.vue';
describe('Counter.vue', () => {
it('renders initial count and increments on click', async () => {
// 1. 渲染组件
render(Counter);
// 2. 查找元素 (Testing Library 推荐按角色、文本等查找)
const countDisplay = screen.getByText(/Count: 0/i); // 使用正则,不区分大小写
const button = screen.getByRole('button', { name: /Increment/i });
// 3. 断言初始状态
expect(countDisplay).toBeInTheDocument(); // 来自 @testing-library/jest-dom (需额外安装和设置) 或自定义匹配器
// 4. 模拟用户交互
await fireEvent.click(button);
// 5. 断言更新后的状态
// Vue 的更新是异步的,Testing Library 的 findBy* 会自动等待
const updatedCountDisplay = await screen.findByText(/Count: 1/i);
expect(updatedCountDisplay).toBeInTheDocument();
// 再次点击
await fireEvent.click(button);
expect(await screen.findByText(/Count: 2/i)).toBeInTheDocument();
});
});
// 可能需要在 setupTests.ts 中扩展 expect (使用 @testing-library/jest-dom)
// import '@testing-library/jest-dom/vitest';
2. React 组件测试 (@testing-library/react
)
-
说明: 类似地,
@testing-library/react
用于测试 React 组件。Vitest 通过 Vite 天然支持 JSX。同样需要jsdom
或happy-dom
环境。 -
安装:
npm install -D @testing-library/react @testing-library/jest-dom
-
示例代码:
// src/components/Greeting.jsx (或 .tsx)
import React, { useState } from "react";
function Greeting({ initialName = "World" }) {
const [name, setName] = useState(initialName);
return (
<div>
<p>Hello, {name}!</p>
<input
type="text"
aria-label="Name Input"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
);
}
export default Greeting;
// test/Greeting.test.jsx (或 .tsx)
import React from "react";
import { describe, it, expect } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import Greeting from "../src/components/Greeting";
// import '@testing-library/jest-dom/vitest'; // 确保在 setup file 中导入
describe("Greeting.jsx", () => {
it("renders greeting and updates name on input change", async () => {
render(<Greeting initialName="Vitest" />);
// 查找元素
const greetingText = screen.getByText(/Hello, Vitest!/i);
const nameInput = screen.getByLabelText(/Name Input/i); // 按 aria-label 查找
expect(greetingText).toBeInTheDocument();
expect(nameInput).toHaveValue("Vitest");
// 模拟输入事件
await fireEvent.change(nameInput, { target: { value: "React" } });
// 断言更新
expect(screen.getByText(/Hello, React!/i)).toBeInTheDocument();
expect(nameInput).toHaveValue("React");
});
});
3. Svelte、Solid 等支持
- 说明: Testing Library 为许多主流框架都提供了相应的包,如
@testing-library/svelte
,@testing-library/solid
。使用方式与 Vue/React 类似:安装对应的库,在jsdom
/happy-dom
环境下使用render
函数挂载组件,然后通过screen
查询和fireEvent
交互。具体用法请参考各库的文档。Vitest + Vite 的组合通常能很好地处理这些框架的编译和测试。
4. 组件挂载与渲染
- 说明: 在框架测试中,“挂载”或“渲染”指的是将组件实例创建并附加到测试环境的模拟 DOM 中。Testing Library 的
render
函数负责处理这个过程,它不仅渲染组件,还返回一些用于调试和查询的工具函数(尽管推荐优先使用全局的screen
对象进行查询)。
5. 测试库(Testing Library)集成
- 说明: Testing Library 是目前社区推荐的 UI 测试实践库。它的核心理念是像用户一样测试:通过用户可见或可交互的方式(如文本内容、标签、ARIA 角色)来查找元素,通过模拟用户事件(点击、输入)来驱动组件,然后断言用户可见的结果。它避免了测试组件的内部实现细节(如 state、props、方法调用),使得测试更加健壮,不易因重构而失败。
- 核心 API:
render()
: 渲染组件。screen
: 提供查询方法(getBy*
,findBy*
,queryBy*
)在整个document.body
中查找元素。fireEvent
: 触发 DOM 事件(click
,change
,submit
等)。waitFor
: 等待某个异步断言变为真。
- 与 Vitest 结合: Vitest 提供测试运行环境和基础断言,Testing Library 提供组件渲染、交互和查询能力。通常还需要
@testing-library/jest-dom
来提供更方便的 DOM 相关断言(如toBeInTheDocument
,toHaveClass
,toHaveValue
等),需要在setupTests.ts
中引入并扩展expect
。
九、调试与开发体验 (DX)
Vitest 提供了多种功能来提升测试的开发和调试效率。
1. watch
模式
- 说明: 运行
vitest
命令(不带run
参数)会启动监听模式。Vitest 会监视项目文件(源文件和测试文件)的变化。当检测到文件更改时,它会自动重新运行相关的测试文件,提供即时反馈。这是 Vitest 提升开发效率的关键特性之一。 - 交互式命令: 在 watch 模式下,可以按键盘快捷键进行交互:
f
: 切换文件名过滤模式,输入文件名或模式只运行匹配的测试。t
: 切换测试名称过滤模式,输入测试名称 (describe
或it
的名称) 只运行匹配的测试。u
: 更新失败的快照。r
: 重新运行所有测试。q
: 退出 watch 模式。
- 示例:
(控制台会显示菜单提示可用的快捷键)
# 启动 watch 模式
pnpm vitest
# 或者 npm run test (如果 script 是 "vitest")
# 或者 yarn test
2. 测试过滤与定位
-
说明: 除了 watch 模式下的交互式过滤,还可以通过多种方式精确地运行你关心的测试:
- CLI 文件路径:
vitest src/components/Button.test.ts
只运行指定文件。 - CLI 文件名模式:
vitest button
会运行文件名包含button
的所有测试文件。 - CLI 测试名称:
vitest -t "should render correctly"
或vitest --test-name-pattern "should render"
只运行it
/test
名称匹配的测试用例。 - 代码内标记: 在
describe
或it
/test
上使用.only
修饰符,如it.only(...)
。运行vitest
时,只有标记了.only
的测试会执行(如果存在多个.only
,它们都会执行)。
- CLI 文件路径:
-
示例:
# 只运行 Button.test.ts
vitest src/components/Button.test.ts
# 只运行名字包含 "login" 的测试用例
vitest -t login
# 运行文件名包含 utils 且测试名包含 "edge case" 的测试
vitest utils -t "edge case"// 只运行这个测试用例
it.only("this specific test case will run", () => {
// ...
});
// 只运行这个 describe 块中的所有测试
describe.only("Focused Test Suite", () => {
it("test A", () => {});
it("test B", () => {});
});
3. 错误追踪与调试
- 说明: 当测试失败时,Vitest 会提供清晰的错误报告:
- 失败断言: 显示期望值 (Expected) 和实际值 (Received) 的差异 (diff)。
- 快照不匹配: 显示新旧快照内容的差异。
- 代码错误: 提供错误的堆栈跟踪 (Stack Trace),指出错误发生在哪个文件的哪一行。
- 调试方法:
console.log
: 在测试代码或被测代码中添加console.log
语句,其输出会显示在测试运行结果中。debugger
: 在代码中添加debugger;
语句。然后使用 Node.js 的调试器运行 Vitest。然后使用 Chrome DevTools (访问# 使用 Node inspect 运行 Vitest
node --inspect-brk ./node_modules/vitest/vitest.mjs run <test_file>chrome://inspect
) 或 VSCode 的 JavaScript Debug Terminal 连接到 Node.js 进程进行调试。- IDE 调试: 使用 VSCode 等 IDE 的调试功能(见下文)。
4. VSCode 插件与断点调试
-
说明: 为了更方便地调试,推荐安装官方的 Vitest VSCode Extension。
- 特性:
- 在编辑器侧边栏(Gutter)显示运行/调试测试的按钮。
- 在“测试”面板中查看和管理测试套件及结果。
- 允许直接在
.ts
/.js
/.vue
等测试文件中设置断点,然后通过点击 Gutter 中的调试按钮启动调试会话。
- 特性:
-
配置 (
.vscode/launch.json
): 虽然插件通常能处理好调试启动,但有时你可能需要自定义调试配置。 -
示例 (
launch.json
):{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Vitest Current File", // 调试当前打开的测试文件
"program": "${workspaceFolder}/node_modules/vitest/vitest.mjs",
"args": [
"run",
"${relativeFile}", // 传入当前文件的相对路径
"--config", // 指定配置文件(如果需要)
"${workspaceFolder}/vitest.config.ts"
],
"smartStep": true,
"skipFiles": ["<node_internals>/**", "**/node_modules/**"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
},
{
"type": "node",
"request": "launch",
"name": "Vitest All", // 调试所有测试
"program": "${workspaceFolder}/node_modules/vitest/vitest.mjs",
"args": [
"run", // 或者 "watch" 模式进行调试
"--config",
"${workspaceFolder}/vitest.config.ts"
],
"smartStep": true,
"skipFiles": ["<node_internals>/**", "**/node_modules/**"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
}
]
}配置好后,可以在 VSCode 的“运行和调试”面板选择相应的配置来启动调试。
5. 输出美化与日志
- 说明: Vitest 默认提供彩色、结构化的控制台输出,清晰地显示测试进度、通过/失败状态和错误信息。测试代码或源文件中使用的
console.log
等日志语句会正常输出到控制台,方便在测试运行时观察内部状态。 - 注意: 虽然
console.log
对于临时调试很有用,但通常建议在最终提交的代码和测试中移除不必要的日志语句,保持测试输出的干净。对于更复杂的日志需求,可以考虑使用专门的日志库,并可能需要配置 Vitest 或测试环境来处理日志输出(例如,在测试期间抑制日志或重定向到文件)。
十、性能与并发
1. 并发测试与隔离
-
说明:
- 并发 (
.concurrent
): 如前所述,.concurrent
允许同一文件内的测试用例并行执行(在同一工作线程内),适合 I/O 密集型测试。 - 隔离: Vitest 默认情况下会尽可能地隔离测试文件。它使用工作线程池 (worker threads) 或子进程 (forks) 来并行运行不同的测试文件(可通过
pool
和poolOptions
配置)。这意味着不同文件间的全局状态通常是隔离的。然而,使用了.concurrent
的同一文件内的测试用例则共享同一个上下文和全局状态,需要开发者自行确保这些并发测试不会相互干扰(例如,避免修改共享的 mock、全局变量或 DOM 状态)。
- 并发 (
-
示例代码 (并发):
import { describe, it, expect, vi } from "vitest";
const createResource = (id: number) => ({ id, data: `Resource ${id}` });
const cleanupResource = vi.fn(); // 共享的 mock 函数
// 如果测试间依赖或修改共享状态(如 cleanupResource),并发可能导致问题
describe.concurrent("Potentially problematic concurrent tests", () => {
it("test 1 modifies shared state", async () => {
const resource = createResource(1);
// 模拟操作
await new Promise((r) => setTimeout(r, 50));
cleanupResource(resource.id); // 调用共享 mock
expect(cleanupResource).toHaveBeenCalledWith(1);
});
it("test 2 also modifies shared state", async () => {
const resource = createResource(2);
// 模拟操作
await new Promise((r) => setTimeout(r, 30));
cleanupResource(resource.id); // 并发调用可能导致调用次数或顺序混乱
expect(cleanupResource).toHaveBeenCalledWith(2);
// 如果 Test 1 的 expect 在此之后运行,可能会失败
});
});
// 更安全的并发测试:确保测试间独立
describe.concurrent("Safer concurrent tests", () => {
const independentCleanup = vi.fn(); // 每个测试用独立的 mock (通过 setup/teardown)
beforeEach(() => independentCleanup.mockClear());
it("test A uses its own context", async () => {
// ...
independentCleanup("A");
expect(independentCleanup).toHaveBeenCalledWith("A");
});
it("test B uses its own context", async () => {
// ...
independentCleanup("B");
expect(independentCleanup).toHaveBeenCalledWith("B");
});
}); -
配置 (示例) (
vitest.config.ts
):test: {
// pool: 'threads', // 默认使用工作线程池
// pool: 'forks', // 使用子进程池,隔离性更强,但开销更大
poolOptions: {
threads: {
// minThreads: 1, // 最小线程数
// maxThreads: 4, // 最大线程数 (默认 CPU 核数)
// useAtomics: true, // 使用 Atomics 实现线程同步,可能更快但兼容性稍差
},
forks: {
// minForks: 1,
// maxForks: 4,
}
}
}
2. 测试分组与分片(sharding)
-
说明:
- 分组 (
describe
): 主要是为了逻辑上组织相关的测试用例,提高可读性和可维护性。它本身不直接控制并行执行机制(除了可以对整个describe
块应用.concurrent
)。 - 分片 (Sharding): 是将整个测试套件分割成多个部分(片),然后在不同的机器或 CI 作业中并行运行这些部分。这是显著减少大型测试套件总执行时间的关键策略。Vitest 通过
--shard
CLI 参数支持分片。
- 分组 (
-
示例代码 (Sharding):
-
在 CI 环境中,假设你有 3 个并行的 CI 作业:
-
作业 1:
vitest run --shard='1/3'
(运行测试套件的第 1 部分,共 3 部分) -
作业 2:
vitest run --shard='2/3'
(运行测试套件的第 2 部分,共 3 部分) -
作业 3:
vitest run --shard='3/3'
(运行测试套件的第 3 部分,共 3 部分) -
Vitest 会根据文件路径或其他策略自动分配哪些测试文件属于哪个分片。
-
示例 CI 配置 (GitHub Actions):
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3] # 定义分片数量
steps:
# ... checkout, setup node, install deps ...
- name: Run tests shard ${{ matrix.shard }} of 3
run: pnpm test --shard='${{ matrix.shard }}/3' --coverage # 在每个作业中运行对应的分片
# ... upload coverage etc. ...
-
3. 性能分析与优化建议
- 说明: 随着测试套件增长,执行时间可能会变长。需要关注性能瓶颈并进行优化。
- 识别慢测试: Vitest 默认会报告运行时间最长的测试(可在配置中调整数量
slowTestThreshold
)。 - 常见瓶颈:
- 环境初始化:
jsdom
或happy-dom
的启动本身有开销。确保不需要 DOM 的测试运行在node
环境。 - 全局
setupFiles
: 过于复杂的全局设置会拖慢每个测试文件的启动。 - 组件渲染: 复杂组件的重复渲染可能很耗时。优化组件本身或使用更轻量级的测试策略(如只测逻辑,不渲染 UI)。
- 低效 Mock: 过于复杂的 Mock 实现或不必要的深度 Mock。
- 串行 I/O: 大量等待网络、文件系统等 I/O 操作的测试,如果相互独立,应考虑使用
.concurrent
。
- 环境初始化:
- 优化建议:
- 选择合适环境:
node
>happy-dom
>jsdom
(速度和资源占用)。 - 优化
setupFiles
: 保持全局设置精简。 - 使用
.concurrent
: 对独立的 I/O 密集型测试启用并发。 - 合理 Mock: 只 Mock 必要的部分,避免过度 Mock。
- 利用缓存: Vitest (和 Vite) 有内置的转换缓存,确保配置正确以利用它。
- 分片 (Sharding): 在 CI 中是最终的大杀器,通过增加硬件资源并行处理。
- Node.js Profiling: 对于极端情况,可以使用
node --prof
或node --inspect
配合 Chrome DevTools 来分析 CPU 性能瓶颈。
- 选择合适环境:
- 识别慢测试: Vitest 默认会报告运行时间最长的测试(可在配置中调整数量
- 示例 (查看慢测试报告): Vitest 执行完毕后,控制台可能会显示类似信息:
✓ src/utils/complex.test.ts (3 tests) 5.8s
✓ should handle scenario A 120ms
✓ should handle scenario B 5500ms (slow) <-- 标记慢测试
✓ should handle scenario C 80ms
十一、CI/CD 与自动化
1. 与 GitHub Actions、GitLab CI 等集成
-
说明: 将 Vitest 集成到 CI/CD 流程是标准实践,确保代码变更不会破坏现有功能。这通常涉及在 CI 配置文件中添加一个步骤来安装依赖并运行测试脚本。
-
示例代码:
-
GitHub Actions (
.github/workflows/ci.yml
):name: CI
on: [push, pull_request]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: "18"
cache: "pnpm" # 或 'npm', 'yarn'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run linters # 可选步骤
run: pnpm lint
- name: Run tests
run: pnpm test --run # 使用 'run' 模式,而不是 watch
# 或者包含覆盖率: run: pnpm test --run --coverage -
GitLab CI (
.gitlab-ci.yml
):image: node:18 # 使用包含 Node.js 的 Docker 镜像
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
- .npm/ # 如果使用 npm cache 或类似
stages:
- test
test_job:
stage: test
before_script:
- npm ci # 或 pnpm install / yarn install
script:
- npm test -- --run # 确保传递 'run' 参数给 vitest
# 或 npm test -- --run --coverage
artifacts: # 可选:保存覆盖率报告等
when: always
paths:
- coverage/
reports:
junit: junit.xml # 如果配置了 JUnit 报告
-
2. 失败重试与稳定性
- 说明: CI 环境有时会因为网络波动、资源竞争等原因导致测试偶然失败(Flaky Test)。Vitest 提供了自动重试失败测试的功能,以提高 CI 流程的稳定性。但过度依赖重试可能掩盖潜在的问题,应优先尝试修复 flaky 测试。
- 配置:
- CLI:
vitest run --retry 3
(重试失败的测试最多 3 次) - 配置文件 (
vitest.config.ts
):test: {
retry: 2, // 重试失败的测试 2 次
}
- CLI:
- 示例: 在 CI 脚本中直接使用 CLI 参数是常见的做法。
# 在 CI 脚本中运行,带重试
pnpm test --run --retry 2
3. 生成测试报告
- 说明: 除了控制台输出,生成标准格式的测试报告(如 JUnit XML)对于 CI/CD 工具集成非常有用。许多 CI 平台可以解析这些报告,并在 UI 中展示详细的测试结果、历史趋势等。
- 配置:
- 配置文件 (
vitest.config.ts
):test: {
reporters: [
'default', // 默认控制台报告器
'junit', // 添加 JUnit 报告器
// 'json', // 添加 JSON 报告器
// 'html' // 添加 HTML 报告器 (本地查看方便)
],
outputFile: {
junit: './junit.xml', // 指定 JUnit 报告输出路径
// json: './test-results.json'
// html: './test-report/index.html'
},
} - CLI (覆盖配置):
vitest run --reporter=junit --outputFile=./junit.xml
- 配置文件 (
- 示例: 配置 JUnit 报告后,CI 平台(如 GitLab, Jenkins)通常可以自动拾取
junit.xml
文件并展示结果。
4. 代码质量与门禁
- 说明: 测试和覆盖率是代码质量的重要保障。在 CI/CD 中,可以将测试结果和覆盖率作为“门禁” (Quality Gate),只有满足特定标准(例如,所有测试通过,代码覆盖率达到阈值)的构建才能继续进行(例如,部署到生产环境或允许合并代码)。
- 实现:
- 测试通过: Vitest 在测试失败时会以非零状态码退出,这通常会自动使 CI 作业失败,阻止流程继续。
- 覆盖率阈值: 在
vitest.config.ts
中设置coverage.thresholds
。如果覆盖率低于阈值,Vitest 默认也会以非零状态码退出(检查 Vitest 文档确认当前版本的具体行为,可能需要配置coverage.failUnderThresholds: true
或类似选项)。
- 示例 (覆盖率门禁配置):
// vitest.config.ts
coverage: {
// ... 其他覆盖率配置 ...
thresholds: {
lines: 85, // 要求行覆盖率达到 85%
functions: 80,
branches: 80,
statements: 85,
// 可以针对特定文件设置更高或更低的阈值
// 'src/critical/**': { lines: 95 },
},
// 确保低于阈值时会失败 (检查 Vitest 版本文档,此选项可能已变更或不再需要)
// failUnderThresholds: true,
}
十二、最佳实践与进阶
1. 测试组织与目录结构
- 说明: 合理的组织结构能提高测试的可维护性。常见模式:
- Colocation (并置): 测试文件与源文件放在同一目录下,例如
src/components/Button.vue
和src/components/Button.test.ts
。优点是查找方便,相关代码在一起。 - Centralized (集中式): 所有测试文件放在一个顶层目录,如
tests/
或__tests__/
,内部再按功能或模块组织子目录,例如tests/components/Button.test.ts
。优点是测试代码与业务代码分离清晰。 - 混合模式: 根据项目规模和团队偏好选择。
- Colocation (并置): 测试文件与源文件放在同一目录下,例如
- 示例:
- 并置:
src/
├── components/
│ ├── Button.vue
│ └── Button.test.ts
└── utils/
├── math.ts
└── math.test.ts - 集中式:
src/
├── components/
│ └── Button.vue
└── utils/
└── math.ts
tests/
├── components/
│ └── Button.test.ts
└── utils/
└── math.test.ts
- 并置:
2. 公共测试工具与自定义 matcher
-
说明:
- 公共工具: 对于跨测试文件复用的设置逻辑(如创建 mock 服务器、生成特定数据)、复杂的断言逻辑或通用的辅助函数,可以抽取到公共的测试工具文件中(例如
tests/test-utils.ts
)。 - 自定义 Matcher: 当需要进行领域特定的断言,使得测试代码更具表达力时,可以扩展 Vitest 的
expect
API,添加自定义匹配器 (Matcher)。
- 公共工具: 对于跨测试文件复用的设置逻辑(如创建 mock 服务器、生成特定数据)、复杂的断言逻辑或通用的辅助函数,可以抽取到公共的测试工具文件中(例如
-
示例代码:
-
自定义 Matcher (
setupTests.ts
或单独文件导入):// setupTests.ts
import { expect } from "vitest";
expect.extend({
// 自定义一个 matcher 检查数字是否为正数
toBePositive(received: number) {
const pass = typeof received === "number" && received > 0;
return {
pass,
message: () =>
`expected ${received} ${pass ? "not " : ""}to be a positive number`,
};
},
});
// 在测试中使用
// it('should be positive', () => {
// expect(10).toBePositive();
// expect(-5).not.toBePositive();
// });
// 需要为 TS 添加类型声明 (例如在 vitest.d.ts)
// interface CustomMatchers<R = unknown> {
// toBePositive(): R;
// }
// declare module 'vitest' {
// interface Assertion<T = any> extends CustomMatchers<T> {}
// interface AsymmetricMatchersContaining extends CustomMatchers {}
// } -
公共测试工具 (
tests/test-utils.ts
):// tests/test-utils.ts
export function createMockUser(overrides = {}) {
return {
id: Math.floor(Math.random() * 1000),
name: "Test User",
email: "test@example.com",
isAdmin: false,
...overrides,
};
}
// 在测试中使用
// import { createMockUser } from '../test-utils';
// it('should create an admin user', () => {
// const adminUser = createMockUser({ isAdmin: true, id: 1 });
// expect(adminUser.isAdmin).toBe(true);
// expect(adminUser.id).toBe(1);
// });
-
3. 测试数据管理与工厂
-
说明: 测试往往需要构造各种输入数据或模拟状态。手动创建复杂对象很繁琐且容易出错。使用测试数据工厂 (Test Data Factories) 或库 (如
@faker-js/faker
生成模拟数据,factory-girl
,fishery
) 可以简化这个过程,生成一致或随机的测试数据。 -
示例代码 (简单工厂函数): (参见上方
createMockUser
示例) -
示例代码 (使用 Faker.js):
// tests/factories/userFactory.ts
import { faker } from "@faker-js/faker"; // npm install -D @faker-js/faker
export function buildUser(overrides = {}) {
return {
id: faker.string.uuid(),
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
email: faker.internet.email(),
avatar: faker.image.avatar(),
isAdmin: false,
createdAt: faker.date.past(),
...overrides,
};
}
// 在测试中使用
// import { buildUser } from '../factories/userFactory';
// it('should generate realistic user data', () => {
// const user1 = buildUser();
// const user2 = buildUser({ isAdmin: true });
// expect(user1.email).toContain('@');
// expect(user2.isAdmin).toBe(true);
// expect(user1.id).not.toBe(user2.id);
// });
4. 大型项目测试策略
- 说明: 在大型项目中,需要一个分层的测试策略,平衡测试覆盖率、执行速度和维护成本。
- 测试金字塔:
- 大量单元测试 (Unit Tests): (Vitest 强项) 测试独立的函数、模块或组件的逻辑,速度快,隔离性好。
- 适量集成测试 (Integration Tests): (Vitest 适用) 测试多个单元如何协同工作,例如组件与其依赖的服务交互、API 路由与控制器等。可能需要 Mock 外部依赖。
- 少量端到端测试 (E2E Tests): (使用 Playwright, Cypress 等) 测试整个应用程序的用户流程,在真实浏览器中运行,覆盖面广但速度慢、不稳定。
- 策略要点:
- 优先编写单元测试覆盖核心逻辑。
- 集成测试验证关键交互点。
- E2E 测试覆盖核心用户场景 (Happy Path)。
- 利用 Vitest 的速度优势进行快速反馈。
- 在 CI 中使用分片、缓存、选择性运行 (如
vitest related <changed_file>
) 优化执行时间。 - 定期审视测试覆盖率报告,识别未测试的关键区域。
- 测试金字塔:
5. 迁移 Jest/Jasmine/Mocha 测试到 Vitest
-
说明: Vitest 设计时考虑了与 Jest API 的兼容性,迁移相对平滑。
- 主要相似点:
describe
,it
,expect
, 钩子函数 (beforeEach
等) 的 API 非常相似。 - 主要差异点/注意点:
- Mocking:
jest.fn()
->vi.fn()
,jest.mock()
->vi.mock()
,jest.spyOn()
->vi.spyOn()
,jest.requireActual()
->vi.importActual()
,jest.spyOn(object, method, 'get')
->vi.spyOn(object, method, 'get')
(Getter/Setter spy 语法可能略有不同,需查阅文档)。 - 全局变量: Jest 默认全局注入 API,Vitest 推荐配置
globals: false
并显式导入 (import { describe, it, expect, vi } from 'vitest'
),但也可以设为true
以兼容。 - 配置:
jest.config.js
->vitest.config.ts
。配置项名称和结构不同,需要转换(例如testEnvironment
->environment
,setupFilesAfterEnv
->setupFiles
)。利用 Vite 的配置是 Vitest 的优势。 - ESM/CJS: Vitest 基于 Vite,原生支持 ESM。如果旧测试基于 CommonJS,可能需要处理模块导入/导出方式的差异,或配置 Vite/Vitest 处理 CJS 依赖。
- 环境: Jest 的环境配置 (
@jest/environment-jsdom
) 需映射到 Vitest 的environment
(jsdom
,happy-dom
)。 - 快照: 快照格式兼容,通常可以直接复用
.snap
文件。
- Mocking:
- 迁移工具: 社区可能有 codemods 或脚本辅助迁移,但手动检查和调整通常是必要的。
- 主要相似点:
-
示例 (语法对比):
// Jest
const mockFn = jest.fn();
jest.mock("../src/utils");
const utils = require("../src/utils");
expect(mockFn).toHaveBeenCalled();
// Vitest
import { vi, expect } from "vitest";
const mockFn = vi.fn();
vi.mock("../src/utils"); // Hoisted
// import * as utils from '../src/utils'; // Import after mock
expect(mockFn).toHaveBeenCalled();
6. 常见问题与排查
- 问题:
document
/window
is not defined.- 原因: 测试运行在
node
环境,或未正确配置jsdom
/happy-dom
环境。 - 排查: 检查
vitest.config.ts
的test.environment
配置。确保已安装jsdom
或happy-dom
。
- 原因: 测试运行在
- 问题: Mock 不生效 (
vi.mock
似乎没起作用)。- 原因:
vi.mock
调用会被提升,确保import
被模拟的模块在vi.mock
之后。或者vi.mock
的路径不正确。或者模块缓存问题。 - 排查: 检查
import
顺序。确认路径。尝试vi.resetModules()
。考虑使用vi.doMock
进行动态模拟。
- 原因:
- 问题: 测试间状态泄漏 (一个测试影响了另一个)。
- 原因: 在
beforeAll
或全局作用域修改了共享状态,或者测试内部没有正确清理副作用(如全局 mock、DOM 修改、定时器)。使用了.concurrent
但测试逻辑不独立。 - 排查: 使用
beforeEach
/afterEach
进行设置和清理。使用vi.clearAllMocks()
/vi.restoreAllMocks()
。避免在并发测试中修改共享状态。
- 原因: 在
- 问题: 测试运行缓慢。
- 原因: 见“性能分析与优化建议”部分。
- 排查: 检查 Vitest 报告的慢测试。分析环境、设置、组件渲染、Mock 等。
- 问题: 无法解析路径别名 (
@/components
)。- 原因: Vitest 配置中未正确设置
resolve.alias
,或者与vite.config.ts
中的别名不一致。 - 排查: 确保
vitest.config.ts
(或共享的vite.config.ts
) 中配置了正确的resolve.alias
。
- 原因: Vitest 配置中未正确设置
- 问题: TypeScript 类型错误 /
vi
全局变量未定义。- 原因:
globals: true
未开启时需要显式导入vi
,describe
等。或者 TS 配置问题 (tsconfig.json
)。 - 排查: 推荐设置
globals: false
并import { ... } from 'vitest'
。检查tsconfig.json
是否正确配置(如types: ["vitest/globals"]
如果使用全局变量)。
- 原因:
十三、生态与扩展
1. 插件系统
-
说明: Vitest 深度集成 Vite,因此可以直接复用 Vite 的插件生态系统。大部分用于构建时转换、资源处理、注入变量等的 Vite 插件通常也能在 Vitest 环境下工作(例如
@vitejs/plugin-vue
,@vitejs/plugin-react
, 处理 CSS/图像的插件等)。这极大地简化了测试环境与开发/构建环境的配置一致性。Vitest 也有自己特定的插件钩子,用于更深度地定制测试行为,但这通常是为库作者或高级用例准备的。 -
示例: 无需特殊配置,如果
vite.config.ts
中配置了插件,Vitest 运行时会自动加载它们。// vite.config.ts (会被 Vitest 读取)
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import myCustomVitePlugin from "./plugins/my-vite-plugin";
export default defineConfig({
plugins: [
vue(), // Vue 插件会在 Vitest 中处理 .vue 文件
myCustomVitePlugin(), // 自定义 Vite 插件也可能在测试中生效
],
test: {
/* Vitest 配置 */
},
});
2. 与 Playwright/Cypress 等端到端测试工具协作
- 说明: Vitest 主要聚焦于单元测试和集成测试,通常在模拟环境(Node.js 或 JSDOM/HappyDOM)中快速运行。而 Playwright、Cypress 等是端到端 (E2E) 测试工具,它们在真实浏览器中运行,模拟用户与整个应用程序的交互流程。它们与 Vitest 是互补关系,共同构成分层的测试策略:
- Vitest: 测试代码逻辑、组件单元、模块集成,速度快,反馈及时。
- Playwright/Cypress: 测试关键用户流程、跨页面导航、真实浏览器兼容性、视觉回归,速度相对慢,但更接近真实用户体验。
- 协作方式:
- 两者通常在不同的测试套件和运行命令下执行。
- 可以在同一个项目中维护,例如
pnpm test:unit
(运行 Vitest) 和pnpm test:e2e
(运行 Playwright/Cypress)。 - CI/CD 流程中可以包含这两个阶段。