--- name: tdd description: 用于通过测试先行(TDD、test-driven development)实现功能或修复 bug;普通的集成测试请求不触发。 --- # 测试驱动开发 TDD 是“先让测试失败(变红),再让它通过(变绿)”的循环。本 skill 说明如何让这个循环产出值得保留的测试:什么是好的测试、测试放在哪里、有哪些反模式、循环有哪些规则。每一节在每一轮都适用:在循环开始前和进行中查阅,而不是事后补看。 探索代码库时,阅读 `CONTEXT.md`(如果存在),让测试名称和接口用词与项目的领域语言一致,并遵守相关区域的 ADR。 ## 好的测试是什么样 测试通过公开接口验证行为,不验证实现细节。即使内部实现整个替换,测试也不需要跟着修改。好的测试读起来像规格:“用户可以用有效购物车结账”准确说明了系统具备什么能力,而且因为不关心内部结构,重构后依然有效。 每个测试都要**说得出它能发现哪种破坏**:写测试体之前,先回答“生产代码怎样修改会让这个测试失败?”答不上来时,围绕一个可观察的行为重新设计这个测试。 示例见 [tests.md](tests.md),mock 准则见 [mocking.md](mocking.md)。 ## 接缝:测试放在哪里 **接缝**(seam)是测试所依托的公开边界:无需深入内部就能观察行为的接口。测试写在接缝上。 **只在事先约定的接缝上测试。** 不可能测试所有内容;事先约定接缝,才能把测试精力放在关键路径和复杂逻辑上,而不是每一个边缘情况。 - 规格的测试决策或任务的“测试”一节已经写明测试层次和接缝时,直接沿用,不再询问。 - 没有写明时,编写任何测试之前写下要测试的接缝,并询问用户:“公开接口是什么?应该在哪些接缝上测试?” ### 测试层次 一个功能通常需要几层测试配合,每层回答不同的问题: - **单元测试**:模块内部的业务规则和计算,在模块接口上测试。 - **契约测试**:模块之间、前后端之间、服务与第三方之间按契约交互时,分别验证提供方和调用方都遵守同一份契约,不必把双方同时跑起来。契约的组织方式见 `codebase-design` skill 目录下的 [CONTRACT-FIRST.md](../codebase-design/CONTRACT-FIRST.md)。 - **集成测试**:模块与真实的数据库、队列等基础设施一起工作是否正确。 - **端到端测试**:用户视角的完整业务流程与核心交互链路的自动化验证。 每一轮循环仍然只写一个测试,写在规格为这个行为指定的那一层。 对接口本身的设计仍有疑问时(模块应该多深、接缝放在哪里、接口应暴露什么),使用 `codebase-design` skill 获取相关术语。它是模块、接口、深度、接缝、适配器、杠杆、局部性等术语的共享来源,用于查阅,不是需要执行的流程。 ## 循环规则 每一轮是一个**端到端最小闭环**:一个接缝、一个测试、一段最少的实现。 1. **变红**:编写一个测试,只描述一个行为。 2. **确认失败原因正确**:运行测试并阅读输出。测试必须**失败**(而不是报错),失败信息符合预期,失败原因是功能缺失(而不是拼写错误或导入错误)。 - 测试直接通过:说明测到的是已有行为,修改测试。 - 测试报错:修复报错并重新运行,直到它以正确的方式失败。 3. **变绿**:只编写恰好让测试通过的代码。只为这个测试编写,后续测试的需求留给后续轮次。 4. **确认通过**:运行该测试和相关测试,阅读输出。全部通过,并且输出干净(没有多余的警告和报错)。 如果实现代码写在了测试之前,就先把那段代码放到一边,从测试重新开始。先写代码会让你照着实现写测试,这样的测试从未证明过自己能够失败。 **重构不属于这个循环。** 重构放在评审阶段(见 `code-review`),不在变红、变绿的实现循环中进行。 ## 反模式 - **与实现耦合**:mock 内部协作者、测试私有方法、绕过接口验证(例如直接查询数据库而不通过接口)。表现:行为没有变化,一重构测试就失败。 - **同义反复**:断言用与代码相同的算法重新计算期望值(`expect(add(a, b)).toBe(a + b)`、用同样方法手工生成的快照、常量等于自身)。这类测试必然通过,永远不会发现代码的错误。期望值必须有独立来源:已知正确的字面量、手算的示例或规格。 - **变更探测器**:只有有意的修改(常量的值、确切的文案、私有结构)才会让它失败。它在重新设计时报警,在真正出 bug 时却不报警。应当测试依赖这项决定的行为:不测 `expect(MAX_RETRIES).toBe(5)`,而测“失败的调用会重试 5 次,不会发生第 6 次”。 - **按层批量编写**:先写完所有测试,再写所有实现。批量写出的测试验证的是*想象中*的行为:测到的是代码的*结构*而不是面向用户的行为,对真实变化不敏感,而且在理解实现之前就固定了测试结构。改为**端到端最小闭环**:一个测试、一段实现,然后重复。每个测试都根据上一轮学到的信息调整方向。 ## 常见借口与对应事实 | 借口 | 事实 | | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | “这段太简单,不用测” | 简单的代码同样会出错,写一个测试只要几十秒。 | | “先写完,再补测试” | 事后补的测试一写就通过,从未证明自己能失败:它可能测错了对象、测的是实现而不是行为,也会漏掉你已经忘掉的边界情况。先写测试。 | | “事后补测试,目的一样” | 事后写的测试回答“这段代码做了什么”,先写的测试回答“它应该做什么”。事后写会被已有代码带偏,只覆盖你记得的情况。 | | “我已经手动测过了” | 手动测试没有记录覆盖了什么,代码改了也无法重跑,压力下容易漏掉情况。自动化测试每次都按同样的方式运行。 | | “删掉几个小时的代码太浪费” | 时间已经花出去了,删不删都收不回来。真正要选的是:用 TDD 重写得到可信的代码,还是留着不可信的代码再补测试。 | | “先留着当参考,照着写测试” | 照着已有代码写测试,就是事后补测试。把代码放到一边,从测试重新开始。 | | “得先探索一下” | 可以探索。探索完把探索代码放到一边,再从测试开始。 | | “这里很难测” | 难测说明接口难用。调整接口设计,需要时使用 `codebase-design` skill。 | | “原有代码本来就没有测试” | 你正在修改它,先在接缝上为要改的行为补测试。 | ## 收尾:变异检查 完成之前,在脑中对生产代码做变异。每种现实可能出现的变异都应至少让一个测试失败: - 常量或参数写错 - 分支的处理逻辑写错 - 缺少状态变更或副作用 - 返回空值或默认值 - 缺少对零值、空值、nil、未授权、格式错误输入的校验 如果某个变异没有测试能发现,说明对应行为没有受到保护,或者测试是同义反复。