创建日期:2026-09-26 | 最近更新:2026-09-26 事实核对基于 vitest 5.0.2(node v24.19.0)在本机的实测;文中所有输出均为真实运行结果,脚本与完整日志留档于
source/vitest-lab/(bash run.sh | tee run.log可复现)。
Vitest 入门:用访问者模式深遍历一个对象,并把测试写对
一句话:一篇「入门」最容易写废的地方,是把它写成 API 清单。 这篇反过来——先挑一个真会写进工程的东西(对象深遍历器,用访问者模式实现),然后回答一个更值钱的问题:这种东西的测试到底该怎么写,才算写对了?
这两个话题看起来不搭,其实是同一件事的两面:
- 访问者模式解决的是「把遍历和操作解耦」——加一个统计逻辑,不用动遍历器一行代码;
- 测试深遍历难的地方正好也在解耦后的那个缝里——遍历器唯一的对外行为是一串回调调用,你没法定值断言,只能断言过程。
本机这套实验:43 条测试,2 个文件,300 ms 跑完;最后用变异测试给「覆盖率 100%」做了一次体检。
Test Files 2 passed (2)
Tests 43 passed (43)
Duration 300ms
0. 先给结论
六条,后文逐条给证据:
| # | 结论 | 证据 |
|---|---|---|
| 1 | Vitest 的入门成本几乎为零:npm i -D vitest + 一个 *.test.ts,不用装 vite、不用写配置 | §1 |
| 2 | 深遍历必须自己检测环——不检测的话,报错是 RangeError(爆栈),而且爆栈前它已经静默访问了约 1.8 万次 | §3.5 |
| 3 | 环检测要用祖先链,不能用「全局见过的集合」——后者会把 DAG 共享节点误判成环 | §3.6 |
| 4 | 测遍历的核心手法只有一个:用「记录器访问者」把过程变成序列,再断言序列 | §4.1 |
| 5 | 覆盖率保护不了你:5 个变异里 4 个的语句覆盖率仍是 100%,只有测试变红了 | §5 |
| 6 | 反过来说,不变量测试比逐条断言顺序更抗重构——它只问「次数对不对」 | §4.2 |
1. Vitest 是什么:一分钟上手
Vitest 是 Vite 生态的测试运行器。它和 Jest 的关系,用一句话概括:API 基本照搬 Jest,内核换成 Vite。
这意味着两件实际的好事:
- 配置复用。你的
vite.config.ts里那套 alias、插件、define,测试里天然就有。再也不用维护一份 Jest 的moduleNameMapper+transform副本。 - 零额外编译。TS、ESM、JSX 都由 Vite 的管线直接处理——没有
ts-jest、没有babel-jest、没有transformIgnorePatterns三件套。
代价是它绑定了 Vite。如果你的项目本来就不在 Vite 上(比如 Webpack 或纯 Node),这层绑定就不是白拿的。
1.1 最小可跑工程
三个文件就够了。注意没有 vite.config.ts,也没有任何必填配置:
// package.json
{
"type": "module",
"scripts": { "test": "vitest run", "watch": "vitest" },
"devDependencies": { "vitest": "^5.0.2" }
}
// src/sum.ts
export const sum = (a: number, b: number): number => a + b;
// test/sum.test.ts
import { describe, it, expect } from "vitest";
import { sum } from "../src/sum.js";
describe("sum", () => {
it("把两个数加起来", () => {
expect(sum(1, 2)).toBe(3);
});
});
跑起来:
npx vitest # watch 模式:改文件自动重跑,本地开发用这个
npx vitest run # 一次性跑完退出,CI 用这个
⚠️ 第一个坑:
vitest和vitest run不是一回事。前者进 watch,跑完不退出——在 CI 脚本里写了vitest,流水线会一直挂着直到超时。本项目run.sh里一律用vitest run。
本机实测的整体耗时(含 node 启动与 npx 开销):1.14 s / 1.14 s / 1.16 s(连跑三次),其中测试自身 300 ms。
1.2 什么时候需要配置文件
vitest.config.ts 只在你要改默认行为时才需要。本项目加它,是因为要排除 demos/(那里放的是故意写坏的代码):
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
exclude: ["node_modules/**", "demos/**"],
coverage: { provider: "v8", include: ["src/**/*.ts"], reporter: ["text", "json-summary"] },
},
});
⚠️ 第二个坑:
exclude是配置层的硬门槛,命令行过滤不掉它。我第一次想单独跑 demo,写了npx vitest run demos/naive-cycle.test.ts,结果是:No test files found, exiting with code 1filter: demos/naive-cycle.test.tsinclude: test/**/*.test.tsexclude: node_modules/**, demos/**正确做法是另开一个配置:
npx vitest run --config vitest.demos.config.ts。
2. 被测对象:一份嵌套订单
后面所有例子都围绕这个结构,它包含了深遍历要处理的所有形态——嵌套对象、数组、原始值、null:
const ORDER = {
id: "A-1",
amount: 120,
buyer: { name: "张三", vip: true, level: 3 },
items: [
{ sku: "x", qty: 2, price: 30 },
{ sku: "y", qty: 1, price: 60 },
],
note: null,
};
3. 访问者模式:为什么遍历需要它
3.1 先看不用它有多疼
需求是:遍历这棵树,做点事。最直觉的写法是递归里塞 if:
function walk(node: unknown, onLeaf: (path: string, v: unknown) => void, path = ""): void {
if (node === null || typeof node !== "object") {
onLeaf(path, node);
return;
}
for (const [k, v] of Object.entries(node)) walk(v, onLeaf, `${path}.${k}`);
}
这段代码能跑,但它和「要做什么」焊死了。接下来每个新需求都会来改它:
| 新需求 | 你被迫改的地方 |
|---|---|
| 还要在「进入」节点时也知道 | 再加一个 onEnter 回调参数 |
| 想剪枝(某棵子树别往下走) | 回调得能返回 false,递归里加判断 |
| 要防环 | 加 seen 参数,所有调用点签名全变 |
| 要限制深度 | 再加一个参数 |
| 要区分「对象/数组/原始值」 | 递归里加分支,分支逻辑散在各处 |
参数列表会一直长下去,而每一个调用点都要跟着改。这就是访问者模式要解决的问题。
3.2 核心机制:双重分派
访问者模式在 GoF 里的经典形态是「两次动态分派」:
第一次分派选中的是「节点的具体类型」,第二次选中的是「访问者的具体实现」。加一个新操作 = 加一个新 Visitor 类,节点类一个字都不用改。
3.3 但在 JS/TS 里,它通常不是这么写的
经典 GoF 形态要求节点类都实现 accept()。在 JS 里,给数据对象挂方法是很别扭的事——尤其是这些对象常常直接来自 JSON.parse,你根本不想动它们。
于是 JS 生态几乎都用了同一个简化形态:把 visitor 做成一个对象字面量,靠 enter / leave 两个可选回调区分时机。你大概率已经用过:
| 库 | 形态 |
|---|---|
estree-walker(Babel/ESLint 生态) | walk(ast, { enter(node, parent) {}, leave(node) {} }) |
unist-util-visit(remark/rehype 生态) | visit(tree, 'type', visitor) + SKIP / EXIT 返回值 |
@vue/compiler-core 的 transform | nodeTransforms,按节点类型分发 |
| webpack / rollup 的 plugin | 本质上就是挂在构建各阶段的 visitor |
所以「JS 里的访问者模式」= 数据不动 + 操作以回调注入 + 靠返回值控制走向。 下面就用这个形态。
3.4 我们的 API
traverse(root, visitor, options?)
| 部分 | 类型 | 说明 |
|---|---|---|
visitor.enter | (node, ctx) => void | boolean | 进入节点(前序)。返回 false = 剪枝,不再往下 |
visitor.leave | (node, ctx) => void | 离开节点(后序)。子节点全处理完才轮到它 |
ctx.path | readonly (string | number)[] | 从根到当前节点的键路径,根是 [] |
ctx.depth | number | 深度,根是 0 |
ctx.kind | "object" | "array" | "value" | 节点分类 |
ctx.parent / ctx.key | unknown / string | number | undefined | 父节点与自己在父节点里的键 |
ctx.cyclic | true | undefined | 见 §3.6 |
options.maxDepth | number | 深度上限,默认 Infinity |
options.onCycle | "throw" | "skip" | 遇到环怎么办,默认 throw |
「加一个操作」现在长这样——加一个统计所有数字之和的操作,遍历器一个字都不用改:
export function sumNumbers(root: unknown): number {
let total = 0;
traverse(root, {
enter(node, ctx) {
if (ctx.kind === "value" && typeof node === "number") total += node;
},
});
return total;
}
配套文档同目录的 src/collect.ts 里还有三个同款操作(收集叶子、拍平、找共享节点)。
3.5 深遍历必须处理的四件事(附一个真实反例)
访问者模式把「遍历」这件苦活集中到了一个函数里,苦活得一次做对。四件事:
① 什么算「能展开」的节点。 不能见到 object 就递归——Date、Map、Set、RegExp、类实例都不是「结构」,展开它们只会得到一堆空对象或意外结果。判断收敛到一个函数里:
export function kindOf(node: unknown): NodeKind {
if (Array.isArray(node)) return "array";
if (node === null || typeof node !== "object") return "value";
const proto = Object.getPrototypeOf(node);
return proto === Object.prototype || proto === null ? "object" : "value";
}
② 环。 这是最要命的一条。看一个故意写坏的版本(demos/naive-cycle.test.ts)——就是 §3.1 那段递归,只是不检测环:
function naiveTraverse(node: unknown, visit: (n: unknown) => void): void {
visit(node);
if (node === null || typeof node !== "object") return;
for (const v of Object.values(node as Record<string, unknown>)) {
naiveTraverse(v, visit); // ← 没有任何环检测
}
}
喂给它 a.child = b; b.back = a,实测输出:
爆栈前 visited = 17871 次
它爆栈了,但在那之前它「成功访问」了约 1.8 万次。 报错是一个 RangeError: Maximum call stack size exceeded——你从中看不出是哪个字段成环。这就是为什么环检测不是可选项。
这个数字每次运行会有浮动(实测另一次是 18133),因为它取决于当时的栈深度。浮动的是数量级之外的零头,不变的是「爆栈前已经静默跑了很多遍」。
③ 深度上限。 就算结构是合法的,也可能深到爆栈(比如一条 5 万层的链)。所以要有 maxDepth,并且要在递归之前判——等栈溢出就晚了。
④ 剪枝与 enter/leave 配对。 enter 返回 false 应该只影响「要不要往下走」,不该影响「leave 还调不调」。否则访问者的 enter/leave 计数就对不上了,而配对的 enter/leave 恰恰是后序逻辑(如聚合、回填)的前提。
3.6 环检测:祖先链,不是「见过的集合」
这是全文最容易写错的一处。直觉写法是维护一个「见过的所有对象」的 Set,遇到就跳过。但那样会误伤 DAG。
{ id: 1 } 被两个分支共享——这不是环,是一个合法的 DAG,它应该被完整遍历两遍(两条路径都要看到)。用全局 Set 的话,第二次遇到它就会被当成环:要么抛错,要么少访问一次。
正确做法是只记「当前这条祖先链」:enter 时加入,leave 时移除。这样「当前节点是不是我自己的祖先」这个问题才是准的:
const ancestors = new Set<object>(); // ← 只放祖先,不是「见过的一切」
// enter 时
if (ancestors.has(obj)) {
if (onCycle === "throw") throw new TraversalCycleError(path);
cyclic = true; // skip:标记出来,不再往下
} else {
ancestors.add(obj);
}
// leave 时 —— 只有当初 add 过的才 delete
if (kind !== "value" && !cyclic) ancestors.delete(node as object);
关于 onCycle: "skip" 时那个 cyclic 标记,见 §4.6——它专门有个测试盯着。
4. 核心问题:怎么测一个深遍历
遍历器的对外行为不是返回值,而是一串回调调用。这决定了测试的基本手法。
4.1 手法一:记录器访问者 —— 把过程变成可断言的序列
这是整套测试里最有用的一个技巧。 写一个只负责「把每次回调记下来」的访问者:
export function createRecorder() {
const events: Array<{ phase: "enter" | "leave"; path: string; depth: number; kind: NodeKind }> = [];
return {
events,
visitor: {
enter: (_node, ctx) => void events.push({ phase: "enter", path: formatPath(ctx.path), depth: ctx.depth, kind: ctx.kind }),
leave: (_node, ctx) => void events.push({ phase: "leave", path: formatPath(ctx.path), depth: ctx.depth, kind: ctx.kind }),
},
};
}
于是「遍历顺序对不对」就变成了一个纯数据断言:
it("前序 + 后序:enter 自上而下,leave 自下而上", () => {
const { visitor, events } = createRecorder();
traverse({ a: { b: 1 } }, visitor);
expect(events).toEqual([
{ phase: "enter", path: "<根>", depth: 0, kind: "object" },
{ phase: "enter", path: "a", depth: 1, kind: "object" },
{ phase: "enter", path: "a.b", depth: 2, kind: "value" },
{ phase: "leave", path: "a.b", depth: 2, kind: "value" },
{ phase: "leave", path: "a", depth: 1, kind: "object" },
{ phase: "leave", path: "<根>", depth: 0, kind: "object" },
]);
});
失败时的输出也好读——Vitest 直接给结构化 diff:
AssertionError: expected [ '<根>', 'a', 'a', 'b', 'b', '<根>' ] to deeply equal [ '<根>', 'a', 'a', 'b', 'b' ]
- Expected
+ Received
@@ -2,6 +2,7 @@
"<根>",
"a",
"a",
"b",
"b",
+ "<根>",
记录器还有第二个好处:它本身就是个可复用的测试夹具。 后面每个用例都从它开始,不用为每个测试单独写 spy。
4.2 手法二:不变量测试 —— 断言「次数对得上」
逐条断言顺序最精确,但也最脆:将来为了性能调整一下遍历策略(比如对象键改成排序后遍历),一堆测试会红,而它们其实测的不是「对不对」。
更抗重构的是不变量:只问「次数对不对」。
it.each([
["扁平对象", { a: 1, b: 2, c: 3 }, 4],
["嵌套对象", { a: { b: { c: 1 } } }, 4],
["数组", [1, [2, [3]]], 6],
["混合", FIXTURE, 9],
])("%s:enter 次数 = 节点总数", (_name, fixture, expected) => {
let enterCount = 0, leaveCount = 0;
traverse(fixture, {
enter: () => void enterCount++,
leave: () => void leaveCount++,
});
expect(enterCount).toBe(expected);
expect(leaveCount).toBe(expected); // leave 一个都不能少
});
对深遍历来说,值得写死的不变量有这么几条:
| 不变量 | 违反意味着 |
|---|---|
enter 次数 = 节点总数 | 漏遍历或重复遍历 |
enter 次数 = leave 次数 | 剪枝时忘了调 leave,或异常路径漏了 |
每个节点的 path 唯一 | 路径拼错 |
depth 与 path.length 一致 | 深度维护错了 |
| 遍历不改变输入 | 见 §4.7 |
4.3 手法三:表驱动 test.each
边界用例天生是一组输入对一个输出,用 it.each 平铺比写十个 it 好读得多:
it.each([
["纯对象", { a: 1 }, "object"],
["数组", [1], "array"],
["null 原型对象", Object.create(null), "object"],
["Date", new Date(0), "value"],
["Map", new Map(), "value"],
["类实例", new (class Foo {})(), "value"],
["null", null, "value"],
["undefined", undefined, "value"],
])("%s → %s", (_name, node, expected) => {
expect(kindOf(node)).toBe(expected);
});
失败时 Vitest 会带上用例名(Date → value),一眼知道是哪一行。
4.4 手法四:spy —— 断言「回调是怎么被调的」
有些事只有 spy 能测:回调被调了几次、参数是什么、调用先后。
it("enter / leave 的次数、参数、先后顺序", () => {
const enter = vi.fn();
const leave = vi.fn();
const input = { a: { b: 1 } };
traverse(input, {
enter: (node, ctx) => void enter(node, [...ctx.path]),
leave: (node, ctx) => void leave(node, [...ctx.path]),
});
expect(enter).toHaveBeenCalledTimes(3); // 根、a、a.b
expect(enter.mock.calls[1]?.[0]).toBe(input.a); // 第 2 次拿到的是 a 本身
expect(enter.mock.calls[2]?.[1]).toEqual(["a", "b"]);
expect(enter.mock.invocationCallOrder[0]).toBeLessThan(
leave.mock.invocationCallOrder[0]!, // 前序必须早于后序
);
});
mock.invocationCallOrder 是这里的关键——toHaveBeenCalledTimes 管不了顺序,全局调用序号才能。
4.5 手法五:环 —— 写出「不会挂住」的测试
测环最怕的不是断言失败,是测试永远不返回。两条经验:
第一,靠 Vitest 的超时兜底。 默认 5 s 超时意味着「实现坏了」会变成一条红色失败,而不是一个挂死的 CI。所以环的测试要正面写,别用 --testTimeout=0 之类把它关掉。
第二,断言错误的「类型 + 内容」,而不只是「抛了」。 光 toThrow() 连「抛的是不是环错误」都不知道:
it("默认抛 TraversalCycleError,并且带上出环的路径", () => {
const cyclic = makeCycle(); // a.child = b; b.back = a
expect(() => traverse(cyclic, {})).toThrow(TraversalCycleError);
expect(() => traverse(cyclic, {})).toThrow(/循环引用/);
});
it("错误对象里带着出环的那条路径,方便定位", () => {
try {
traverse(makeCycle(), {});
expect.unreachable("应当抛错"); // ← 不抛也要算失败
} catch (err) {
expect(err).toBeInstanceOf(TraversalCycleError);
expect(formatPath((err as TraversalCycleError).path)).toBe("child.back");
}
});
注意 expect.unreachable():「没抛错」必须显式判失败,否则 try 块静默通过,测试永远绿。
4.6 手法六:为「那个 bug」专门写一条测试
有些测试的价值不在于覆盖率,而在于它精确地钉住了一个曾经写错的地方。§3.6 的「祖先链 vs 全局 seen」就是典型:
const shared = { id: 1 };
const DAG = { left: { leaf: shared }, right: { leaf: shared } };
it("共享节点会被访问两次,且不报错", () => {
const hits: string[] = [];
traverse(DAG, {
enter(node, ctx) {
if (ctx.kind !== "value" && node === shared) hits.push(formatPath(ctx.path));
},
});
expect(hits).toEqual(["left.leaf", "right.leaf"]);
});
it("反例:如果实现里用的是「全局 seen 集合」,这条会挂", () => {
let n = 0;
traverse(DAG, { enter(node) { if (node === shared) n++; } });
expect(n).toBe(2);
});
这类测试值得在注释里写明它是为哪个 bug 准备的——否则后来的人重构时会觉得它莫名其妙,顺手删掉。§5 的变异实验会证明:删掉它,M1 就漏了。
环的 skip 模式也有一条这样的测试。它断言的是「环上的节点仍然 enter 一次、并且被 cyclic 标出来」:
traverse(cyclic, {
enter(_node, ctx) {
paths.push(formatPath(ctx.path));
if (ctx.cyclic) flagged.push(formatPath(ctx.path));
},
}, { onCycle: "skip" });
expect(paths).toEqual(["<根>", "name", "child", "child.name", "child.back"]);
expect(flagged).toEqual(["child.back"]);
这条测试是改实现改出来的。最初的
skip分支是return在enter之前——节点被静默跳过,访问者根本不知道那里断了。写测试时发现「期望和实际对不上」,才意识到这是个设计缺陷,于是加了ctx.cyclic标记。测试先于实现暴露问题,这是它最值钱的时刻。
4.7 手法七:断言「没有副作用」
遍历器不改输入,这是个容易被忽略、但会引发灾难性 bug 的约定(想想它被用在 Redux store 或 Vue 响应式对象上)。用 structuredClone 拍快照前后对比:
it("遍历前后,原对象深比较完全一致", () => {
const input = structuredClone(ORDER);
const snapshot = structuredClone(input);
collectLeaves(input);
sumNumbers(input);
flatten(input);
expect(input).toEqual(snapshot);
});
顺带把 toBe / toEqual 的区别也钉住——这是两个最常被混用的断言:
it("访问者拿到的是原引用,不是副本", () => {
const inner = { deep: true };
let seen: unknown;
traverse({ inner }, { enter(node, ctx) { if (ctx.depth === 1) seen = node; } });
expect(seen).toBe(inner); // toBe = 同一个引用
});
it("只有值相同、不是同一个引用,不算共享", () => {
const tree = { a: { id: 1 }, b: { id: 1 } };
expect(tree.a).toEqual(tree.b); // 深比较相等
expect(tree.a).not.toBe(tree.b); // 但不是同一个对象
expect(findShared(tree).size).toBe(0);
});
4.8 手法八:对拍 —— 再写一个「独立实现」
深遍历的输出面很大,逐条断言容易漏。一个便宜又强的办法:在测试文件里再手写一个实现(可以很笨、很慢、不考虑性能),然后拿两边结果对拍。
/** 不用访问者,手写一遍:只在测试里当「第二个实现」 */
function countSharedByHand(root: unknown): number {
const count = new Map<object, number>();
(function rec(node: unknown) {
if (node === null || typeof node !== "object") return;
const proto = Object.getPrototypeOf(node);
if (!Array.isArray(node) && proto !== Object.prototype && proto !== null) return;
count.set(node, (count.get(node) ?? 0) + 1);
for (const v of Object.values(node as Record<string, unknown>)) rec(v);
})(root);
return [...count.values()].filter((n) => n > 1).length;
}
注意它必须真的「独立」——如果它也调用 traverse,那就变成了自己跟自己比,一点用没有。
4.9 类型也要测:vitest --typecheck
访问者的回调签名是这套 API 最容易写错的地方(enter 能返回 boolean,leave 不能)。运行时测不出这些,得靠类型测试。Vitest 用 *.test-d.ts 文件 + expectTypeOf:
test("enter 允许返回 boolean(剪枝),也可以什么都不返回", () => {
expectTypeOf<Visitor["enter"]>().toEqualTypeOf<
((node: unknown, ctx: VisitContext) => void | boolean) | undefined
>();
});
test("path 是只读的 —— 类型层面就挡住了「就地改路径」", () => {
const ctx = {} as VisitContext;
// @ts-expect-error path 是 readonly,不允许 push
ctx.path.push("x");
});
跑法:
npx vitest --typecheck run
实测输出(注意它连运行时用例一起跑):
Test Files 3 passed (3)
Tests 48 passed (48)
Type Errors no errors
Duration 1.11s (typecheck 77%, transform 14%, import 4%, tests 4%, worker 1%)
官方在输出里自己标了:
Testing types with tsc and vue-tsc is an experimental feature.所以版本要锁死,别用^让它自动升。
5. 覆盖率 100% 保护了什么?做一次变异测试
跑 vitest run --coverage,本项目的覆盖率是全满的:
File | % Stmts | % Branch | % Funcs | % Lines | Stmts | Branch
---------------|---------|----------|---------|---------|-------|-------
src/collect.ts | 100 | 100 | 100 | 100 | 25/25 | 14/14
src/visitor.ts | 100 | 100 | 100 | 100 | 52/52 | 40/40
合计 statements 77/77 branches 54/54 functions 19/19
「100% 覆盖率」在这种时候特别容易让人安心。但我们来做一次体检:手工往实现里注入 5 个「看起来很像真的」的 bug,每次都重新跑测试和覆盖率(scripts/mutation-check.mjs):
id | 变异 | 失败用例 | 语句覆盖率 | 测试逮住了吗
----|------------------------------------------|----------|------------|--------------
M1 | ancestors 只加不删(等价于「全局 seen 集合」) | 2 | 100% | ✅ 逮住(覆盖率没掉)
M2 | maxDepth 判定差一(> 改成 > + 1) | 1 | 93.5% | ✅ 逮住
M3 | 忽略 enter 返回的 false(剪枝失效) | 1 | 100% | ✅ 逮住(覆盖率没掉)
M4 | formatPath 不再给特殊键加引号(一律用 .k) | 1 | 100% | ✅ 逮住(覆盖率没掉)
M5 | leave 只在非剪枝时调用(破坏 enter/leave 配对) | 1 | 100% | ✅ 逮住(覆盖率没掉)
这张表要横着读:
- 5 个变异全部被测试逮住 —— 说明这套测试是真有用的,不是刷覆盖率刷出来的。
- 其中 4 个,语句覆盖率仍然是 100%。 也就是说:光看覆盖率,你完全不知道这几个 bug 存在。
- 只有 M2(差一错误)让覆盖率掉到 93.5%——因为它改变了控制流,有分支走不到了。
结论一句话:覆盖率回答的是「这行代码有没有被执行到」,不是「它做对了没有」。 它是必要的地板,不是天花板。真想知道测试够不够,得做变异——把代码改坏,看测试红不红。
5.1 顺带发现的两个坑
做这次变异实验时踩到两个 Vitest 的实际行为,都值得记下来:
① 测试失败时,Vitest 默认不写覆盖率报告。 coverage/ 目录压根不会建。也就是说「改坏之后覆盖率掉没掉」这个问题,默认根本无从回答。要拿到失败时的覆盖率,得显式打开:
npx vitest run --coverage --coverage.reportOnFailure=true
② text reporter 的逐文件表在本机是空的。 只剩 summary:
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
------------|---------|----------|---------|---------|-------------------
------------|---------|----------|---------|---------|-------------------
Statements : 100% ( 77/77 )
TTY 与非 TTY 都试过,都一样。但同一次运行的 json-summary 里逐文件数据是全的——所以我在 scripts/coverage-table.mjs 里自己渲染了一张表,而不是去猜 reporter 为什么不输出行。CI 上建议直接用 lcov / json-summary 这类机器可读的 reporter,别依赖 text 的排版。
6. 速查表
| 想干什么 | 命令 / 写法 |
|---|---|
| 本地开发(watch) | npx vitest |
| CI 一次性跑 | npx vitest run |
| 跑单个文件 | npx vitest run test/traverse.test.ts(注意 exclude 是硬门槛) |
| 按名字过滤 | npx vitest run -t "循环引用" |
| 覆盖率 | npx vitest run --coverage |
| 失败时也要覆盖率 | npx vitest run --coverage --coverage.reportOnFailure=true |
| 类型测试 | npx vitest --typecheck run |
| 表驱动 | it.each([...])("%s → %s", (name, input, expected) => {}) |
| 断言调用次数/顺序 | vi.fn() + toHaveBeenCalledTimes + mock.invocationCallOrder |
| 断言「不该走到这」 | expect.unreachable("应当抛错") |
| 断言同一个引用 | toBe(toEqual 是深比较) |
| 结构不变 | structuredClone 拍快照 + toEqual |
| 只要一个测试的 console | --disable-console-intercept(默认通过用例的日志不显示) |
7. 坑清单
- CI 里写了
vitest而不是vitest run→ 流水线挂到超时。 - 想用文件名过滤掉
exclude里的目录 → 报No test files found。exclude是配置层硬门槛,要用--config另开一份。 - 深遍历不检测环 →
RangeError爆栈,且爆栈前已静默访问约 1.8 万次(实测)。 - 环检测用全局
seen集合 → DAG 共享节点被误判成环,遍历结果静默变少(M1 变异,测试逮住了)。 enter返回false后忘了调leave→ enter/leave 计数不配对,所有后序逻辑(聚合、回填)全错(M5 变异)。toThrow()不带参数 → 抛任何错都算通过,包括实现里手滑抛的TypeError。- 只断言顺序、不写不变量 → 重构时一片红,但真出 bug 时未必红。
- 测
Date/Map/ 类实例时假设它们会被展开 → 它们被当叶子,测试期望写错。 expect(input).toEqual(input)这种自比 → 永远通过。无副作用测试必须先拍快照。- 覆盖率到 100% 就以为安全了 → 实测 5 个变异里 4 个覆盖率不掉。
8. 来源与核对
本机实测(node v24.19.0 / vitest 5.0.2 / @vitest/coverage-v8 5.0.2):文中所有数字与代码输出均来自 source/vitest-lab/run.log,跑法 bash source/vitest-lab/run.sh。
| 引用 | 来源 |
|---|---|
Testing types with tsc and vue-tsc is an experimental feature. | vitest --typecheck run 的真实输出(逐字) |
No test files found / filter: … include: … exclude: … | 单独跑 demos/ 时的真实输出(逐字) |
--coverage.reportOnFailure | Vitest 官方配置项,实测行为与文档一致(默认不写报告) |
--disable-console-intercept | Vitest CLI 选项,实测用于显示通过用例里的 console.log |
estree-walker / unist-util-visit 的 visitor 形态 | 这两个库的公开 API(未逐字核对其文档原文,仅按 API 形态描述) |
| GoF 访问者模式的「双重分派」 | 设计模式通识,未引用具体版本的原文 |
一句自我限定:§3.3 里「JS 生态几乎都用简化形态」是我的观察与推断,不是统计结论——我只列举了几个自己用过的库。