mcpField
C
characterisation-tests

characterisation-tests

github.com/citypaul测试与质量760184 次安装4 周前更新
代码仓库

关于 characterisation-tests

用于通过临时特征化测试记录现有代码实际行为的技能,适合旧代码或规范不足的场景。通过先失败、再运行和观察的流程支持安全重构,之后可用行为驱动测试替换这些测试。

主要功能

  1. 01记录观察到的行为而非正确性
  2. 02用失败占位断言发现实际输出
  3. 03规定临时测试的命名与文件约定
  4. 04涵盖异步行为、计时和长输出
  5. 05说明测试的替换与移除时机

适用场景

  1. 01重构缺少充分测试的旧代码
  2. 02规范缺失时调查代码行为
  3. 03修改函数前记录其输出
  4. 04控制时序并刻画异步路径

安装

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

$npx skills add https://github.com/citypaul/.dotfiles --skill characterisation-tests

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

附带文件resources/modern-tooling.mdresources/writing-process.md

详细介绍

特征测试

为了先让不可测试的代码变得可测试,请加载 finding-seams 技能。为了通过测试驱动开发实现新行为,请加载 tdd 技能。对于通用测试模式,请加载 testing 技能。进行特征测试时,请遵循关注变异的测试设计规则。如果仓库要求进行变异测试,或者变更风险足以证明添加变异测试是合理的,请在仓库选定的验证节点为累积的变更加载 mutation-testing。

深入学习资源位于 resources/ 目录中。按需加载:

资源加载时机……
writing-process.md需要一个完整特征测试流程的实例,涵盖针对性测试、异步代码和何时停止的指导
modern-tooling.md被测代码返回多行文本、渲染后的文档或大型对象(快照测试和审批测试),想要遍历大量输入组合,或行为具有非确定性

核心概念

特征测试是用于描述某段代码实际行为的测试。它不涉及“它应该这样做”——测试记录的是系统实际上会做什么。

特征测试不具有道德权威。它们不验证正确性——只检测变化。特征测试失败时,由人来判断变化是否符合预期。它也称为黄金主测试或审批测试——概念相同,名称不同。

—— Michael Feathers,《Working Effectively with Legacy Code》(2004)

适用场景

  • 修改没有测试(或测试不足)的现有代码
  • 规范缺失、不完整,或与运行中的系统相矛盾
  • 代码过于复杂,无法仅靠阅读来理解
  • 面临遗留代码困境:需要测试才能安全重构,但代码难以测试
  • 需要在修改函数前了解它实际返回什么

不适用场景

  • 全新代码——新代码从一开始就应采用测试驱动开发(参见 tdd 技能)
  • 已有规范——如果需求明确且代码是新的,应编写断言预期行为的行为驱动测试,而不是记录代码当前任意行为的特征测试
  • 代码已有充分测试——只对未测试的部分进行特征测试;不要重复已有的覆盖
  • 作为永久测试策略——特征测试是脚手架;重构时应将其替换为正式测试

命名与识别

特征测试必须一眼就能识别——无论是对其他 LLM、人类,还是未来的自己而言。阅读测试文件的人应该能立即明白:这些测试记录的是实际行为,并非对正确性的断言,而且计划临时使用。

测试命名

在测试名称中使用 characterises,以区别于行为驱动测试:

// ✅ Clearly identified as characterisation tests
describe('calculateBonus characterisation', () => {
  it('characterises premium-member bonus for < 5 years', () => { ... });
  it('characterises business-member loyalty threshold', () => { ... });
});

// ❌ Indistinguishable from behavior-driven tests
describe('calculateBonus', () => {
  it('should apply a premium-member bonus', () => { ... });
});
文件命名

使用独特的文件后缀,使特征测试在文件树中能一眼区分:

scoring.characterisation.test.ts    ← characterisation tests (temporary)
member-bonus.test.ts                ← behavior-driven tests (permanent)

临时特征测试文件可以与实现文件一一对应——它用于固定该文件当前的行为,之后会被删除。永久测试应围绕行为组织,而非按文件边界划分(testing 技能的“不要一一对应”规则)。

测试内文档

在每个特征测试文件顶部添加块注释,说明测试目的和计划的生命周期。这是少数注释必不可少的地方之一——测试本身记录代码做了什么,而注释说明这些测试为何存在,以及何时移除它们:

/**
 * CHARACTERISATION TESTS -- documenting actual behavior, NOT asserting correctness.
 *
 * These tests pin down the current behavior of calculateBonus so we can
 * safely refactor it. They should be replaced with behavior-driven tests
 * as the code is understood and restructured.
 *
 * See: characterisation-tests skill for the methodology.
 */
describe('calculateBonus characterisation', () => { ... });
可疑行为

当特征测试捕获到看起来像缺陷的行为时,请明确标记:

it('characterises negative activity handling -- SUSPICIOUS: returns negative bonus', () => {
  // This may be a bug -- negative activity produces a negative bonus.
  // Documented as-is; escalate before changing.
  expect(calculateBonus(-5, 'premium', 3)).toBe(-0.75);
});

算法

  1. 使用 测试框架中的一段代码
  2. 编写一个你确定会失败的断言(使用 "PLACEHOLDER" 之类的虚拟值)
  3. 运行测试,让失败告诉你行为是什么 —— 测试运行器会显示实际值
  4. 修改测试,使其期望代码实际产生的行为
  5. 重复 —— 让好奇心指引你;代码本身会提示你接下来该测试什么

切勿输入一个你没看过运行结果的期望值。 每个期望值都来自执行真实模块:失败的占位符断言、记录的快照,或导入该模块的临时测试。通过阅读代码推导答案,或者把函数复制到一次性脚本中运行副本,都不属于特征测试 —— 这只是在固定你对代码的理解,而误读会变成一项为错误背书的测试。先写占位符、运行,然后粘贴运行器打印的内容:测试文件至少要编写两次,中间运行一次。

通过特征测试文件/名称或有针对性的一次性运行,专注发现预言机。任何可复用的监视器都应遵循 tdd 技能规定的标准种子、生命周期、清理和验证策略;不要假设在干净基线上运行的 --changed 监视器已加载实现依赖图。预言机稳定后,从 monorepo 根目录使用仓库自有或由运行器得出的受影响范围,确保传递依赖的使用方仍有资格参与测试。PR 就绪时,停止监视器,并执行目标仓库的变异策略,以及涵盖所有已配置项目的完整非监视模式门禁。

// Step 2: write assertion you know will fail
it('characterises formatPrice', () => {
  expect(formatPrice(1999)).toBe('PLACEHOLDER');
});
// Test output: expected 'PLACEHOLDER' but received '$19.99'

// Step 4: change test to expect actual behavior
it('characterises formatPrice', () => {
  expect(formatPrice(1999)).toBe('$19.99');
});

超过一行的输出 —— 渲染文本、格式化文档、大型对象 —— 应使用内联快照固定,而非手动输入字符串。 不带参数调用 toMatchInlineSnapshot(),运行一次,运行器就会将观察到的输出写入测试文件:上述算法由此实现自动化,而且只要有一个字符错误就会失败。只有当期望内容短到一眼即可读完时,才手动输入。固定多行输出前,请先加载 modern-tooling.md,以了解内联/文件快照的选择、组合扫描和非确定性。

启发式

  1. 以有针对性的覆盖率作为指南 —— 先覆盖变更区域(例如 Vitest 的变更/相关覆盖率或所属测试项目),然后运行完整的必需覆盖率门禁,并在最终运行仓库范围的 PR 前测试前完成所有修复
  2. 生产行为是兼容性证据,而非正确性裁定依据 -- 在使用方、可达性、安全性和已接受需求方面的证据明确是否必须保留之前, 将已部署的观察结果视为对兼容性敏感
  3. 聚焦变更区域 —— 不必对整个代码库进行特征测试,只需测试即将修改的代码
  4. 标记可疑行为 —— 发现疑似错误时,在测试中记录并标记为可疑;不要悄悄“修复”
  5. 查看代码 —— 这些不是黑盒测试;阅读代码,以指导要对哪些路径进行特征测试
  6. 制定适度的测试强度证据计划 —— 在进行特征测试时,使用变异规则以较低成本弥补明显缺口。如果仓库选择运行自动化变异测试,则等累积的变更达到选定验证节点后,再配置测试框架。覆盖率能告诉你哪些路径已被执行;变异测试则能显示哪些路径得到了保护。

何时停止

你不需要覆盖整个代码库的 100%。满足以下条件时即可停止:

  • 即将进行的更改涉及的每个分支都有表征测试覆盖
  • 更改点外延一层的代码也有覆盖(调用你正在更改的代码或被其调用的分支)
  • 你将修改的路径中,可能出现突变风险的部分已在表征示例中体现;只有在仓库策略或更改风险要求时,才运行自动化突变检查

如果你无法确信测试能检测出即将修改的特定代码中的错误,就增加更多测试。如果可以确信,就停止。

发现 Bug 时

所有遗留代码都有 Bug。在表征过程中发现 Bug 时:

  • 如果系统已部署:可能有人依赖这种“有 Bug 的”行为。请记录该行为,将测试标记为可疑,在兼容性决策作出之前予以保留,并在更改之前升级处理。
  • 如果系统尚未部署:兼容性风险较低,但尚未部署并不代表行为正确。请先依据权威需求或用户决定明确预期行为;然后记录观察到的故障,修复它,并用验证预期行为的回归测试替换旧行为表征测试。
  • 保留证据,而非永久保留一个失败断言:只有在有意保留旧行为期间,针对该行为的测试才应继续存在。

表征测试是临时的

表征测试可为重构提供保障,之后应由恰当的行为驱动测试替代:

  1. 表征——明确当前行为,将其作为安全网
  2. 重构——调整代码结构,同时通过表征测试检测任何行为变化
  3. 替换——随着对代码的理解加深,编写断言预期行为的恰当测试
  4. 移除——恰当的测试覆盖相同行为后,弃用表征测试

就像走进一片森林并画出一条线:“这片区域都归我管。”了解这一点后,你就可以通过重构和编写更多测试来开发它。随着时间推移,表征测试可以逐渐移除。

表征异步代码

异步遗留代码需要采用相同的算法——关键区别在于要等待结果并控制时序。

// Step 1: dummy assertion, same algorithm
it('characterises fetchUserOrders', async () => {
  const result = await fetchUserOrders('user-123');
  expect(result).toBe('PLACEHOLDER');
});
// Output: expected 'PLACEHOLDER' but received [{ id: 'order-1', ... }]

// Step 2: record actual behavior
it('characterises fetchUserOrders for known user', async () => {
  const result = await fetchUserOrders('user-123');
  expect(result).toEqual([
    expect.objectContaining({ id: 'order-1', status: 'shipped' }),
  ]);
});

异步表征的关键注意事项:

  • 使用真实的 I/O 接缝——将异步依赖作为参数传入,而不是调用真实服务(参见 finding-seams 技能)
  • 错误路径——表征已解决和被拒绝的状态:await expect(fn()).rejects.toThrow()
  • 依赖时序的行为——使用 vi.useFakeTimers() 和 vi.advanceTimersByTime() 控制时间(参见 modern-tooling.md)
  • 流和事件——将发出的值收集到数组中,然后断言收集到的结果
// Characterising an event emitter
it('characterises order processor events', async () => {
  const events: string[] = [];
  processor.on('status', (s: string) => events.push(s));
  await processor.process(testOrder);
  expect(events).toEqual(['validating', 'processing', 'complete']);
});

常见错误

错误修正方法
将特征测试视为永久测试这些测试只是脚手架——重构时应替换为行为驱动测试
在特征测试中“修复”Bug记录实际行为,将其标记为可疑,并向上反馈
试图为整个代码库编写特征测试聚焦于即将修改的区域,以及向外一层
根据代码应该实现的行为编写特征测试让代码告诉你它实际做了什么——使用上面的算法
通过阅读代码或在临时脚本中复制函数来计算预期值运行真实模块,并从运行器的输出或记录的快照中获取所有预期值
将覆盖率视为测试强度的证明针对可能的变异风险添加断言;当仓库策略或变更风险需要时,运行累计范围的变异测试
将特征测试用于新代码新代码应采用测试驱动开发(参见 tdd 技能)
使用 vi.mock() 感知行为,而不是注入参数将感知函数作为参数传入(参见 finding-seams 技能)
未等待异步结果在特征测试中使用 async/await——对 Promise 进行同步断言总会通过

评论

支持 Markdown