关于 unit-testing
面向使用 Jest、Vitest 或 pytest 的开发者,介绍单元测试的设计与验证方法。重点包括测试替身、有效覆盖率门槛、快照、假计时器和变异测试。
主要功能
- 01Jest、Vitest 和 pytest 模式
- 02桩、间谍、模拟与伪实现指南
- 03覆盖率阈值配置
- 04快照与假计时器实践
- 05用于行为检查的变异测试
适用场景
- 01设置 CI 覆盖率门槛
- 02为外部边界选择测试替身
- 03确定性测试依赖时间的行为
- 04找出无法捕获逻辑改动的测试
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ petrkindlmann/ qa-skills --skill unit-testing安装后在 Claude Code 中输入/unit-testing确认已加载。
附带文件references/patterns.md
详细介绍
发现问题
先检查 .agents/qa-project-context.md——如果文件存在,就使用其中的信息,并跳过已回答的问题。
- 框架: Jest、Vitest 还是 pytest?检查
package.json或pyproject.toml。测试运行器决定配置键和模拟 API。 - 覆盖率工具: 是否已配置?查找
jest.config.*、vitest.config.*、.nycrc、[tool.coverage]。这决定你是要添加门槛,还是只需调整门槛。 - 模拟策略: 手动模拟、自动模拟还是依赖注入?检查
__mocks__/目录或 DI 容器——这决定你会使用哪些测试替身。 - 约定: 与源文件放在一起(
*.test.ts),还是放在__tests__/tests/目录树中?遵循现有做法;不要引入第三种位置。
核心原则
1. 测试行为,而非实现。 验证代码做什么,而非如何做。重构内部实现不应导致测试失败。
// Bad — implementation detail // Good — observable behavior
expect(svc._cache.size).toBe(3); expect(svc.getUser("abc")).toEqual({ id: "abc", name: "Alice" });
2. 快速、隔离、确定。 不访问网络、磁盘或数据库。不共享可变状态。不使用不受控的 Date.now() 或 Math.random()——用伪计时器和设定好的值将其固定。
3. 准备、执行、断言。 每个测试都采用清晰的结构。
it("should apply discount for orders over $100", () => {
// Arrange
const order = createOrder({ subtotal: 150 });
const svc = new DiscountService(0.1);
// Act
const result = svc.apply(order);
// Assert
expect(result.total).toBe(135);
});
4. 每个测试聚焦一个断言概念。 多个 expect 调用没有问题,只要它们验证的是同一概念。
5. 使用描述性名称。 用 "should [behavior] when [condition]",而不是 "test calculateTotal"。
特定框架的模式
每种测试运行器的完整设置/清理、模拟、间谍、计时器、源码内测试和单体仓库示例都在 references/patterns.md 中。以下介绍当前版本和适用场景;代码请从参考文档中复制。
Jest
当前版本是 Jest 30.x(30.4.2,2026 年 5 月)。Jest 30 新增了 --collect-tests、jest.config.mts 支持、支持 Temporal 的伪计时器,以及 clearMocksOnScope。如果被测代码使用 Temporal API 或时区逻辑,Jest 30 支持 Temporal 的伪计时器可以消除一类脆弱的测试设置。
适用工具:用 jest.mock() 处理模块边界(部分模拟可用 jest.requireActual),用 jest.spyOn() 包装真实方法,用 jest.Mocked<T> 创建类型化模拟,并用 jest.useFakeTimers() 处理时间。参见 references/patterns.md § Jest。
Vitest
API 与 Jest 相同,并原生集成 Vite。稳定版本:Vitest 4.1.x(2026 年 6 月);5.0.0-beta 已发布(beta.3,2026 年 5 月)。Vitest 4 新增了 coverage.changed(仅统计变更文件的覆盖率)、mockThrow/mockThrowOnce,以及稳定的浏览器模式。Vitest 5 beta 移除了 sequential 选项,并要求使用 Node 22 / Vite 6.4——请等待稳定版发布后再采用。使用 vi.mock/vi.spyOn 进行模拟;其突出功能包括源码内测试(import.meta.vitest)和用于组件渲染的浏览器模式。参见 references/patterns.md § Vitest。
pytest
使用 fixture 和 conftest.py(用 yield 执行清理),用 @pytest.mark.parametrize 编写数据驱动的测试用例,并用 monkeypatch 替换环境变量或属性。优先使用 fixture,而非 setUp/tearDown 方法——fixture 可以组合,并能按测试隔离。参见 references/patterns.md § pytest。
Bun / Deno
如果运行环境本来就是 Bun 或 Deno,bun test(兼容 Jest,无需额外配置)和 deno test(原生 TS,带权限标志)都是合理的默认选择。对于需要更丰富插件生态的 Node 项目,优先选择 Vitest/Jest。
Mocking 分类
选择能满足需求的最简单测试替身。大多数时候,存根就够了。
| 测试替身 | 功能 | 适用场景 |
|---|---|---|
| 存根 | 返回预设数据,不做验证 | 控制依赖项的返回值 |
| 间谍 | 包装真实实现并记录调用 | 不改变行为,只验证调用 |
| 模拟对象 | 替换实现并记录调用 | 既控制返回值,又验证交互 |
| 伪实现 | 简化但可运行的实现(内存数据库) | 复杂的有状态依赖项 |
经验法则: 优先使用存根,而非模拟对象;有状态依赖项才使用伪实现;
单元测试中绝不调用真实的外部 API。只模拟外部边界
(网络、文件系统、数据库、时间),让快速且确定的内部协作者真实运行,
否则就会得到一套虽然全绿、集成却已损坏的测试。代码中的四种测试替身见:references/patterns.md § 测试替身。
覆盖率
配置
Jest — 阈值键是**coverageThreshold**(单数)。复数形式
coverageThresholds不是 Jest 键:Jest 会静默忽略它,门槛永远不会生效,
CI 即使覆盖率只有 30% 也会保持全绿。这是最常见的配置错误。
// jest.config.js
module.exports = {
coverageProvider: "v8",
collectCoverageFrom: ["src/**/*.ts", "!src/**/*.{d,test,stories}.ts", "!src/**/index.ts"],
coverageThreshold: { global: { branches: 80, functions: 80, lines: 80, statements: 80 } },
};
Vitest — 在 vitest.config.ts 中设置 test.coverage.thresholds,并使用 provider: "v8"
(完整配置块见 references/patterns.md § Vitest)。
pytest:
# pyproject.toml
[tool.coverage.run]
source = ["src"]
omit = ["src/**/test_*.py", "src/**/conftest.py"]
[tool.coverage.report]
fail_under = 80
show_missing = true
exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:"]
覆盖率类型及门槛设置依据
| 类型 | 衡量内容 | 盲点 |
|---|---|---|
| 分支 | 是否走过每条 if/else 路径? | 无法发现未测试的值组合 |
| 行 | 是否执行过每一行? | 无法发现同一行内未测试的分支 |
| 语句 | 是否执行过每条语句? | 与行覆盖率类似 |
| 函数 | 是否调用过每个函数? | 无法反映正确性 |
优先级: 分支 > 行 > 语句 > 函数。以行覆盖率 80% 作为基础门槛, 而非追求表面数字,并提高分支覆盖率的权重。重点覆盖业务逻辑、 转换、错误路径和边界情况;跳过生成代码、类型定义、 barrel 导出、简单 getter 和框架样板代码。
解读哪些未覆盖的代码行值得关注并进行差距分析,应使用
coverage-analysis,而非此技能。
CI 门槛
Jest 和 Vitest 在未达到阈值时会以非零状态码退出——这个退出码就是门槛。pytest 则需要显式指定标志:
- run: pytest --cov=src --cov-fail-under=80
变异测试
覆盖率告诉你代码运行过哪些。变异测试告诉你测试能否
捕获错误。它会对源代码做小幅修改(> → >=、true → false),然后针对每个变异体重新运行测试套件。如果测试套件仍然通过,这个变异体就存活了——测试执行了这段逻辑,却没有对此进行断言。
Stryker(JS/TS)
npm i -D @stryker-mutator/core @stryker-mutator/jest-runner # or vitest-runner
// stryker.config.json (Stryker's documented default; .mjs/.mts also load)
{
"testRunner": "jest",
"coverageAnalysis": "perTest",
"mutate": ["src/**/*.ts", "!src/**/*.test.ts"],
"thresholds": { "high": 80, "low": 60, "break": 50 },
"reporters": ["html", "clear-text", "progress"]
}
Stryker 自带的默认值为 { high: 80, low: 60, break: null }——break: null 表示不会以失败状态退出。设置 break(例如 50),让低分导致 CI 失败。运行:npx stryker run。
mutmut(Python)—— mutmut 3.x
mutmut 3 移除了旧版 CLI 接口。请在 [mutmut] 配置块中配置路径,运行后再通过 TUI 查看存活的变异体:
# setup.cfg (or a [tool.mutmut] table in pyproject.toml)
[mutmut]
paths_to_mutate=src/
pip install mutmut # 3.5.x
mutmut run # paths come from config, not a flag
mutmut browse # interactive TUI: inspect and retest survivors
mutmut apply <mutant_id> # write a survivor to disk to see what it changed
避免使用:
mutmut run --paths-to-mutate=src/、mutmut results和mutmut show 42— 这些属于 mutmut <3 的接口。--paths-to-mutate标志已移除(路径改在[mutmut]配置块中设置),results/show已由browse/apply取代 (mutmut 3.5.x,已于 2026 年 6 月验证)。在当前版本中使用旧命令会报错。
解读分数
| 分数 | 含义 |
|---|---|
| 90%+ | 强——能捕获大多数逻辑变更 |
| 70–89% | 尚可——检查关键路径上存活的变异体 |
| <70% | 测试执行了代码,但未验证行为 |
对关键业务逻辑运行变异测试,而非整个代码库(运行很慢)。 忽略等价变异体——逻辑上完全相同的代码,任何测试都无法区分。
快照测试
适用于: UI 组件渲染输出、序列化数据结构、CLI 格式化输出——结构必须精确且逐字段断言繁琐的输出。
不适用于: 经常变动的输出(快照疲劳 → 审查流于形式)、大型快照(无法审查)、实现细节(CSS 类、内部 ID),或在某个特定值才是重点时替代针对性断言。
小型输出(少于 20 行)优先使用内联快照;对于 ID 和时间戳等动态字段,使用属性匹配器(expect.any(String))。运行 CI 时始终使用 --ci,这样未知快照会导致失败,而不是被静默写入并提交。
代码:references/patterns.md § 快照测试。
反模式
测试私有方法——通过公共 API 进行测试。如果私有方法确实需要单独测试,就将其提取到具有公共接口的独立模块中。
什么都 Mock——只 Mock 外部边界(网络、文件系统、DB、时间)。如果测试套件中的每个协作者都被 Mock,即使它们之间的连接已损坏,测试仍会通过。
复数形式的 coverageThresholds——Jest 会忽略它;门槛永远不会触发;无论覆盖率如何,CI 都会通过。正确的键是单数形式的 coverageThreshold。参见上文的覆盖率部分。
盲目伪造所有计时器——不带白名单地使用 jest.useFakeTimers() / vi.useFakeTimers(),可能导致等待真实微任务的代码死锁。只伪造测试所需的内容(doNotFake / toFake)。参见 references/patterns.md § Jest 计时器。
异步测试未使用 await——忘记 await 会导致断言永远不会运行,测试却会无条件通过。在异步测试中添加 expect.assertions(n) / expect.hasAssertions(),这样缺少断言时测试就会失败。
过度使用快照——对于特定值,使用 expect(x).toBe("active");快照仅用于无法逐字段断言的结构化输出。
名称缺乏描述性——将 "works" 替换为 "should return empty array when no items match the filter"。
共享可变状态——在 beforeEach 中初始化,而不是在模块作用域中初始化:
// Bad: shared mutation // Good: fresh per test
const items = []; let items: string[];
it("A", () => items.push("a")); beforeEach(() => { items = []; });
it("B", () => { it("A", () => { items.push("a"); expect(items).toHaveLength(1); });
items.push("b"); it("B", () => { items.push("b"); expect(items).toHaveLength(1); });
expect(items).toHaveLength(1); // FAILS
});
验证
证明测试套件能够运行,并且覆盖率不足时门槛确实会触发——这正是 coverageThreshold 拼写错误会悄悄禁用的功能。
- 测试运行并通过:
npx jest(或vitest run、pytest -q)以0退出。 - 门槛确实生效。 运行覆盖率检查,并确认低于门槛时会以非零状态退出:
暂时将门槛设为高于当前覆盖率的值(例如 99),并确认命令会失败。如果以npx jest --coverage --ci # Jest/Vitest exit !=0 below coverageThreshold vitest run --coverage # same for Vitest pytest --cov=src --cov-fail-under=80 # pytest exits !=0 below the floor0退出,说明门槛键有误(很可能用了复数形式的coverageThresholds)。 - 快照在 CI 中安全: 运行时使用
--ci,因此未知快照会导致失败,而不是被写入。CI 模式运行后,git status不会显示新生成的*.snap。
完成标准
- 已在
jest.config.*(键为单数形式的coverageThreshold)、vitest.config.*(coverage.thresholds)或pyproject.toml(fail_under)中配置覆盖率门槛,并已验证低于门槛时会以非零状态退出(验证步骤 2) - 所有测试文件都位于项目选定的单一位置(与源码并置,或位于
__tests__/tests/)——git ls-files不会显示临时添加的测试路径 - 外部边界(HTTP、DB、时间)已 Mock,内部协作者未被 Mock——
grep不会在测试文件中找到实际构造的网络/DB 客户端 - 没有测试会访问进程边界之外的内容——禁用网络且不运行测试 DB 时,测试套件仍能通过
- CI 使用
--ci运行测试命令(Jest/Vitest),使未知快照导致构建失败,而不是自动写入
参考文件(位于 references/ 中)
- patterns.md——各框架完整且可运行的示例:Jest 设置/清理、模块/间谍/计时器 Mock、异步保护;Vitest 配置、源码内测试、并发、浏览器模式;pytest fixtures/parametrize/monkeypatch;Bun/Deno;四种测试替身;快照文件/内联/属性匹配器。
相关技能
- coverage-analysis — 解读覆盖率报告、找出有意义的缺口,并将变异分数作为一等信号。前往此处阅读覆盖率;留在这里配置并设置门禁。
- ci-cd-integration — 流水线中的测试阶段、并行化、缓存和部署门禁。
- ai-test-generation — AI 根据规范/PRD 编写测试代码时使用;此技能用于手动编写和组织测试。
- ai-qa-review — 审查现有测试,检查是否存在幻觉 API、伪造导入和闭环测试。
- shift-left-testing — 围绕这些测试配置提交前钩子、IDE 集成和 TDD 工作流。