mcpField
U
unit-testing

unit-testing

github.com/petrkindlmann测试与质量2221.2k 次安装6 天前更新
代码仓库

关于 unit-testing

面向使用 Jest、Vitest 或 pytest 的开发者,介绍单元测试的设计与验证方法。重点包括测试替身、有效覆盖率门槛、快照、假计时器和变异测试。

主要功能

  1. 01Jest、Vitest 和 pytest 模式
  2. 02桩、间谍、模拟与伪实现指南
  3. 03覆盖率阈值配置
  4. 04快照与假计时器实践
  5. 05用于行为检查的变异测试

适用场景

  1. 01设置 CI 覆盖率门槛
  2. 02为外部边界选择测试替身
  3. 03确定性测试依赖时间的行为
  4. 04找出无法捕获逻辑改动的测试

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/petrkindlmann/qa-skills --skill unit-testing

安装后在 Claude Code 中输入/unit-testing确认已加载。

附带文件references/patterns.md

详细介绍

编写在代码错误时失败、正确时通过的单元测试——不能更弱。把每个协作者都模拟掉的测试,即使集成已损坏也会保持绿色;下文的测试替身分类法可以避免这种情况。`coverageThreshold` 拼写错误(或 Jest 会静默忽略的复数形式 `coverageThresholds`)会让覆盖率只有 40% 的代码在流水线显示绿色时仍然发布;下文的配置和验证部分会确保门槛真正生效。本技能涵盖 Jest、Vitest 和 pytest:测试替身、覆盖率门槛、快照、伪计时器,以及作为覆盖率之外行为检查的变异测试。

发现问题

先检查 .agents/qa-project-context.md——如果文件存在,就使用其中的信息,并跳过已回答的问题。

  1. 框架: Jest、Vitest 还是 pytest?检查 package.json 或 pyproject.toml。测试运行器决定配置键和模拟 API。
  2. 覆盖率工具: 是否已配置?查找 jest.config.*、vitest.config.*、.nycrc、[tool.coverage]。这决定你是要添加门槛,还是只需调整门槛。
  3. 模拟策略: 手动模拟、自动模拟还是依赖注入?检查 __mocks__/ 目录或 DI 容器——这决定你会使用哪些测试替身。
  4. 约定: 与源文件放在一起(*.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 拼写错误会悄悄禁用的功能。

  1. 测试运行并通过: npx jest(或 vitest run、pytest -q)以 0 退出。
  2. 门槛确实生效。 运行覆盖率检查,并确认低于门槛时会以非零状态退出:
    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 floor
    
    暂时将门槛设为高于当前覆盖率的值(例如 99),并确认命令会失败。如果以 0 退出,说明门槛键有误(很可能用了复数形式的 coverageThresholds)。
  3. 快照在 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 工作流。

评论

支持 Markdown