关于 write-tests
write-tests 是一项测试技能,指导开发者围绕可观察行为编写回归测试。它强调逐个测试、优先系统级覆盖、控制测试边界,并验证回归测试能否捕获预期故障。
主要功能
- 01一次编写并验证一个测试
- 02优先覆盖可观察的系统行为
- 03控制变量并模拟系统边界
- 04隔离在个人设备上运行的测试
- 05完成前验证回归测试可失败
- 06检查断言中的脆弱假设
适用场景
- 01为行为变更补充回归覆盖
- 02测试跨系统组件的公开路径
- 03诊断不稳定或依赖配置的测试
- 04确认回归测试能检测真实故障
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ dzhng/ skills --skill write-tests安装后在 Claude Code 中输入/write-tests确认已加载。
详细介绍
编写测试
好的测试只会在真实行为出问题时失败,而只要重构或配置变更保留了原有行为,它就会通过。大多数糟糕的测试恰恰相反:无害的变更会让它变红,真实路径出问题时它却仍然是绿的。以下每条规则都服务于同一个目标。
回归覆盖默认使用端到端测试或系统级测试;只有在针对特定的复杂业务逻辑,且快速反馈能显著加快开发时,才使用单元测试。
要判断新增覆盖是否提供独立的验证、应放在哪里,或哪些现有测试可以删除,请使用 audit-tests。
工作流:示踪弹,而非一批测试
- 一次只编写一个测试。 先写断言,观察它在尚未修复的代码上变红,再让代码通过测试,汲取经验,然后编写下一个。绝不要一开始就写一批:基于想象中的行为编写的一批测试,会把你的猜测固定下来——机制失效时这些测试会通过,机制正常时反而会失败。每次变绿都能告诉你下一个测试真正应该断言什么。
- 使用最快的专用运行器迭代(单个文件、单个测试名称),并只在交付前的最后一道关卡运行完整测试套件。检查退出代码,而不只是输出——运行器没有打印内容和测试通过看起来一样。遇到失败时,先阅读每个变红的测试再修复其中一个;它们往往有共同的根因。
- 宣布完成之前,证明测试确实能失败(见下文),并逐项检查审核清单。
断言什么
- 通过实际可用的最外层入口观察到的行为——返回值、退出代码、持久化的行、HTTP 响应、渲染结果——绝不检查运行了哪些内部函数或某个值是如何计算出来的。针对公共接口的测试能在底层全部重写后继续有效;深入内部的测试会在每次重构时失效,固定的是实现,而非行为。只有当逻辑足够特殊且复杂,以至于更快的反馈能显著加快开发时,才使用隔离的单元测试。
- 不测试编译器已经保证的内容。 重新断言类型签名(字段形状、被拒绝的参数类型)的测试,只会在编译器先出错时才失败。把精力花在业务规则、算术、分支、顺序、边界情况和副作用上。
- 断言实际值,而不是集合大小。 对去重、规范化和幂等路径而言,即使规范化已经失效,
length == 1仍会通过;还要断言存储的值等于预期的规范形式。 - 保持系统场景精简。 使用能够证明端到端行为所需的最少数据和机制,避免牵扯无关的断言。
- 依据设计契约,而非当前行为。 测试变红时,人们往往会本能地重新测量并固定新的数值——要抵制这种冲动:把阈值校准到代码当前的表现,会在不知不觉中把错误编成基线。按照明确的契约编写断言,让代码通过测试;如果没有契约,这应该是向负责人提出的问题,而不是测量后固定的数值。只有契约本身发生变化时,重新校准才合理。
- 确定性断言要严格,随机性断言要按分布处理。 每次运行都成立的不变量,应设定精确阈值。任何有噪声或依赖调优的情况(“A 通常优于 B”、“负载均衡分布均匀”),都必须在一组运行或随机种子上以范围来断言——对随机结果只固定单次样本并非薄弱的测试,而是盲目的测试:它会认证幸运样本的结果,并可能掩盖系统性偏差数月之久。如果必须断言有噪声的差异,应扩大容差,并在注释中将其标记为 chaos-marginal。
接缝与模拟对象
- 不要让测试耦合配置——模拟接缝。 如果测试依赖实时配置值(哪个标志已启用、哪个层级是默认值),有人合理地修改配置后,测试就会失败。解决办法绝不是
skipIf/条件判断——跳过断言会掩盖这种耦合,并导致测试不再覆盖相应路径。为函数提供固定输入,或对查询进行桩替换,让固定装置 ID 解析为固定值。检验标准:“如果配置值变了,但逻辑没变,这个测试会失败吗?” - 测试应自行设定所依赖的边界。 调优数据(单位属性、范围、价格、时长)肯定会变:它们总是在调整,而读取这些数据或硬编码当前值的测试,每次调整都会失败。因此,边界测试(在范围内还是范围外、可见还是隐藏、能否容纳还是溢出)应使用由测试自行定义数值的假对象,并探测边界两侧。其他测试则使用能体现行为的最小配置,远离任何边界:这样既能经受调优,也能更快运行。
- 不要过度模拟——每个模拟对象都是一个冻结的假设。 在系统边界处模拟(网络、时钟、文件系统、第三方 SDK、配置查询),不要模拟内部协作者。对内部组件进行桩替换会硬编码它当前的契约;重构后,测试就会失真。如果存在可观察结果,绝不要把“是否以某参数调用”作为主要断言。为了测试一个函数而模拟三个内部模块,说明应该在上一层测试,让这些模块使用真实实现。
- 测试框架必须按生产环境的方式接线系统。 如果某个 bug 只在真实环境中出现,说明测试框架漏掉了生产环境总会设置的某项输入——应修复测试框架,而不只是修复 bug。
控制变量与探测
- 每次比较只改变一个变量。 固定其他所有条件——两组使用相同的种子、数量和配置;禁用与测试对象无关的机制。比较测试失败时,先确认是否有无关机制影响了实验,再修改被测代码。
- 验证,不要臆测。 绝不要仅凭推理就断定原因——触发了哪条路径、失败源自何处——并据此采取行动。编写一次性的探测代码,读取公开状态并输出结果;看似合理的解释经常是错的,足以浪费整个调试时段,而一次探测就可能扭转整个排查方向。探测代码只是临时用品:放在不会被提交的位置,问题一旦得到解答就立即删除;如果相关行为值得长期防护,则将其转为正式测试。
测试会在他人的日常使用设备上运行
会占用桌面的测试套件,只会让人不再运行它。无论测试需要什么,都应以干扰最小的方式实现:
- 绝不抢占焦点。 不激活应用、不调用
makeKeyAndOrderFront、不置顶、不移动指针、不在会话中模拟点击、不播放音频。即使 UI 不能打断用户,也完全可以测试:构建窗口、驱动其模型,并断言渲染视图的结果。如果证据需要截图,就在后台捕获窗口自身的图像,而不是拍摄屏幕。将激活操作保留在用户会触发的代码路径中,并通过调用处理程序来测试,而不是反复启动应用。 - 绝不写入用户保存个人数据的位置。 将每项测试指向临时主目录、临时偏好设置域或套件,以及临时安装位置。测试绝不能修改真实配置、资料库、登录项或已安装应用。生产环境可以读取测试设置的覆盖值;没有该覆盖值时,必须正常运行。
- 将不可避免的可见操作合并执行。 如果某项检查确实需要真实启动应用,应在一次运行中完成所有观察,而不是每个断言都重新启动一次;并在证据中注明该操作会显示在屏幕上。
- 不留下任何仍在运行的内容。 测试启动的每个进程、窗口、服务和临时文件都必须停止并清理,包括测试失败时。
证明测试确实会失败
从未见过失败的测试只是摆设。对于任何回归测试——尤其是修复之后才编写的测试——都要验证一次它是否会失败:按 bug 发生时的情况回退或破坏生产代码,确认测试因预期原因变红,恢复代码,再确认测试变绿。验证回退确实生效:stash 或使用了错误 pathspec 的 checkout 可能什么也没回退,却不报任何提示,而所谓的“变红”运行实际上悄悄测试了修复后的代码。判断方法:变红时的数字和变绿时相同。
更改后测试变红时
在编辑任何内容之前,按可能性从高到低排查:
- 测试编码了已删除的语义——它锁定了旧实现中的偶然行为。重新定义测试,使其符合契约的真实要求。
- 实验失去了对变量的控制——新的机制产生了干扰。固定该变量。
- 余量紧得像混乱状态下的临界值。放宽余量,并添加注释说明原因。
- 代码确实有误。探查状态,找出作用机制。
切勿为了让某个测试通过而调整常量,却不重新运行相关测试:耦合系统会重新洗牌。如果连续两次调整各自导致不同的测试失败,就停止试探——控制面不对;找出作用机制。
评审清单
检查任何测试差异,在同一轮中修复问题,然后重新运行测试套件:
- 某个断言是否只是重述了类型保证?→ 删除它。
- 它是否会因配置编辑而失败,即使逻辑没有变化?或者它是否根据配置使用了
skipIf?→ 对接缝进行模拟。 - 断言是否大多是针对模拟对象内部的“调用参数”?→ 往上一层测试可观察的输出。
- 它是否只锁定了某个随机结果的单个样本?→ 改为断言分布。
- 在去重/规范化路径中,它是否断言了数量,却没有断言值?→ 添加值断言。
- 生产环境中是否仍会调用被测函数?→ 如果调用者只有测试,就同时删除该函数和测试。
- 它能否因正确的原因失败?→ 验证一次。
- 它是否会获取焦点、移动指针、播放声音,或写入用户的真实配置、库或应用程序?→ 改为驱动模型并使用临时位置,还要在屏幕外捕获窗口。
评论
支持 Markdown