关于 characterisation-tests
用于通过临时特征化测试记录现有代码实际行为的技能,适合旧代码或规范不足的场景。通过先失败、再运行和观察的流程支持安全重构,之后可用行为驱动测试替换这些测试。
主要功能
- 01记录观察到的行为而非正确性
- 02用失败占位断言发现实际输出
- 03规定临时测试的命名与文件约定
- 04涵盖异步行为、计时和长输出
- 05说明测试的替换与移除时机
适用场景
- 01重构缺少充分测试的旧代码
- 02规范缺失时调查代码行为
- 03修改函数前记录其输出
- 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);
});
算法
- 使用 测试框架中的一段代码
- 编写一个你确定会失败的断言(使用
"PLACEHOLDER"之类的虚拟值) - 运行测试,让失败告诉你行为是什么 —— 测试运行器会显示实际值
- 修改测试,使其期望代码实际产生的行为
- 重复 —— 让好奇心指引你;代码本身会提示你接下来该测试什么
切勿输入一个你没看过运行结果的期望值。 每个期望值都来自执行真实模块:失败的占位符断言、记录的快照,或导入该模块的临时测试。通过阅读代码推导答案,或者把函数复制到一次性脚本中运行副本,都不属于特征测试 —— 这只是在固定你对代码的理解,而误读会变成一项为错误背书的测试。先写占位符、运行,然后粘贴运行器打印的内容:测试文件至少要编写两次,中间运行一次。
通过特征测试文件/名称或有针对性的一次性运行,专注发现预言机。任何可复用的监视器都应遵循 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,以了解内联/文件快照的选择、组合扫描和非确定性。
启发式
- 以有针对性的覆盖率作为指南 —— 先覆盖变更区域(例如 Vitest 的变更/相关覆盖率或所属测试项目),然后运行完整的必需覆盖率门禁,并在最终运行仓库范围的 PR 前测试前完成所有修复
- 生产行为是兼容性证据,而非正确性裁定依据 -- 在使用方、可达性、安全性和已接受需求方面的证据明确是否必须保留之前, 将已部署的观察结果视为对兼容性敏感
- 聚焦变更区域 —— 不必对整个代码库进行特征测试,只需测试即将修改的代码
- 标记可疑行为 —— 发现疑似错误时,在测试中记录并标记为可疑;不要悄悄“修复”
- 查看代码 —— 这些不是黑盒测试;阅读代码,以指导要对哪些路径进行特征测试
- 制定适度的测试强度证据计划 —— 在进行特征测试时,使用变异规则以较低成本弥补明显缺口。如果仓库选择运行自动化变异测试,则等累积的变更达到选定验证节点后,再配置测试框架。覆盖率能告诉你哪些路径已被执行;变异测试则能显示哪些路径得到了保护。
何时停止
你不需要覆盖整个代码库的 100%。满足以下条件时即可停止:
- 即将进行的更改涉及的每个分支都有表征测试覆盖
- 更改点外延一层的代码也有覆盖(调用你正在更改的代码或被其调用的分支)
- 你将修改的路径中,可能出现突变风险的部分已在表征示例中体现;只有在仓库策略或更改风险要求时,才运行自动化突变检查
如果你无法确信测试能检测出即将修改的特定代码中的错误,就增加更多测试。如果可以确信,就停止。
发现 Bug 时
所有遗留代码都有 Bug。在表征过程中发现 Bug 时:
- 如果系统已部署:可能有人依赖这种“有 Bug 的”行为。请记录该行为,将测试标记为可疑,在兼容性决策作出之前予以保留,并在更改之前升级处理。
- 如果系统尚未部署:兼容性风险较低,但尚未部署并不代表行为正确。请先依据权威需求或用户决定明确预期行为;然后记录观察到的故障,修复它,并用验证预期行为的回归测试替换旧行为表征测试。
- 保留证据,而非永久保留一个失败断言:只有在有意保留旧行为期间,针对该行为的测试才应继续存在。
表征测试是临时的
表征测试可为重构提供保障,之后应由恰当的行为驱动测试替代:
- 表征——明确当前行为,将其作为安全网
- 重构——调整代码结构,同时通过表征测试检测任何行为变化
- 替换——随着对代码的理解加深,编写断言预期行为的恰当测试
- 移除——恰当的测试覆盖相同行为后,弃用表征测试
就像走进一片森林并画出一条线:“这片区域都归我管。”了解这一点后,你就可以通过重构和编写更多测试来开发它。随着时间推移,表征测试可以逐渐移除。
表征异步代码
异步遗留代码需要采用相同的算法——关键区别在于要等待结果并控制时序。
// 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 进行同步断言总会通过 |