全栈手账 订阅 RSS

TDD 测试驱动开发

Test-Driven Development:先写测试,再写实现。 核心不是「多写测试」,而是用测试来驱动设计——在写代码之前,先明确「它应该做什么」。

读了 34 分钟 更新于 10 月 28 日

一、什么是 TDD

1.1 定义

TDD 是一种开发节奏:在写产品代码之前,先写一个会失败的测试,然后写刚好能让它通过的最简代码,最后在测试的保护下重构代码结构。

1.2 红-绿-重构循环

文本
        ┌──────────────────────────────┐        │                              │        ▼                              │   🔴 红:写一个失败的测试              │        │                              │        ▼                              │   🟢 绿:用最简代码让它通过            │        │                              │        ▼                              │   🔵 重构:在测试保护下改进结构        │        │                              │        └──────────────────────────────┘                 (几分钟一轮)
阶段 目标 关键纪律
🔴 红 确认测试确实会失败 先看它失败,且失败原因是预期的(不是语法错或环境问题)
🟢 绿 让它通过,越简单越好 允许「丑陋但正确」,禁止顺手实现下一个需求
🔵 重构 消除重复、改善命名 全程测试必须保持绿;不改行为,只改结构

1.3 与传统开发的区别

维度 传统「后补测试」 TDD
顺序 写代码 → 想测试 写测试 → 写代码
测试的作用 验证「已完成的代码」 驱动设计、约束接口
接口设计 写完才发现难测 先写测试时就被迫想清楚调用方式
覆盖率 通常只覆盖 happy path 天然覆盖边界与异常
重构信心 低(改完不知道坏没坏) 高(测试即安全网)
调试成本 出问题靠打日志猜 定位到具体断言

1.4 最容易被误解的一点

TDD 的第一价值不是「保证质量」,而是迫使你先想清楚接口和边界。

写测试时你会立刻遇到这些问题:

  • 这个方法该叫什么名字?参数是什么?
  • 空输入怎么办?异常怎么抛?
  • 返回结构是什么?

这些问题在写代码前想清楚,比写完再改便宜得多。


二、核心流程详解

2.1 红灯阶段

文本
动作:写一个针对「下一个最小行为」的测试要求:  1. 测试代码要能编译/运行(不是伪代码)  2. 测试必须失败  3. 失败原因必须是「功能未实现」,而不是「测试写错了」

反例(无效红灯):

javascript
// 测试本身有语法错误 → 失败原因是语法错,不是功能缺失 → 无效test('计算折扣', () => {  expect(calculateDiscount(100).toBe(10)  // 少了右括号})

验证技巧:写完测试先跑一遍,看报错是否符合预期。若是 TypeError: calculateDiscount is not a function —— 这是有效的红灯;若是 SyntaxError —— 是测试自己写错了。

2.2 绿灯阶段

文本
动作:写刚好能让当前失败测试通过的最小实现禁止:  ✗ 顺手实现下一个需求  ✗ 提前优化性能  ✗ 过度抽象允许:  ✓ 硬编码返回值(是的,先硬编码)  ✓ 丑陋的实现

为什么要允许硬编码?因为下一个测试会逼你把它变通用。这是 TDD 里最重要的技巧:让测试来驱动抽象,而不是提前设计抽象。

javascript
// 第 1 轮绿灯(可以就这么"丑陋")function calculateDiscount(amount) {  return 0   // 唯一通过的测试是「0 元无折扣」}// 第 2 轮测试加入后,被迫变成真实逻辑function calculateDiscount(amount) {  if (amount >= 200) return 30  if (amount >= 100) return 10  return 0}

2.3 重构阶段

文本
动作:在测试全绿的前提下改善代码允许改:命名、结构、抽取函数、消除重复禁止改:对外行为(测试不应需要修改)

判断标准:如果重构导致测试需要修改,说明你改动了行为——那不是重构,是功能变更。

2.4 循环节奏

粒度 单轮耗时 说明
正常 2–10 分钟 一个断言级的小行为
过长 > 30 分钟 说明这一轮切得太大,应拆小
过短 < 30 秒 可能在做纯格式化改动,不需要 TDD

三、TDD 三条定律

Robert C. Martin(Uncle Bob)提出的经典约束:

# 定律
1 不允许写任何产品代码,除非是为了让一个失败的测试通过
2 不允许写多于一个的失败测试
3 不允许写多于刚好让当前失败测试通过的产品代码

三条定律的作用

  • 第 1 条:杜绝「先写代码再补测试」
  • 第 2 条:保证每一轮足够小,不会一次测试七八个行为
  • 第 3 条:杜绝过度实现,防止代码里出现「暂时没人用的逻辑」

现实中的弹性执行

三条定律是训练纪律,不是教条。实际项目中合理的偏离:

场景 合理偏离
探针式验证(不熟悉的技术方案) 先写几十行 spike 代码探索,探索完丢弃,再正式 TDD
纯 UI 布局调整 不强求 TDD,靠视觉验收或 E2E
数据迁移脚本 用一次性校验脚本而非单测
学习新库的用法 可以先跑官方示例

判断原则:逻辑复杂度高、易出错、会被反复修改的代码,值得 TDD;一次性、纯展示、无分支的代码,收益有限。


四、测试金字塔

文本
        ╱╲          E2E(端到端)        ~5%   慢、脆、成本高       ╱  ╲      ╱────╲       集成测试            ~15%  中等     ╱      ╲    ╱────────╲     单元测试            ~80%  快、稳、TDD 主战场   ╱__________╲
层级 测什么 速度 稳定性 TDD 适用度
单元测试 单个函数/类的行为 毫秒级 高 ★★★★★ 主力
集成测试 模块间协作(DB、HTTP、消息) 百毫秒–秒级 中 ★★★☆☆ 部分
E2E 用户视角的完整流程 秒–分钟级 低 ★☆☆☆☆ 少用

关键认知

  1. TDD 主要作用在单元层。集成与 E2E 更多是「补验证」,不追求 TDD 循环。
  2. 测试越多越好是错的。大量脆弱的 E2E 会拖垮 CI,维护成本反噬开发效率。
  3. 倒金字塔是反模式:E2E 为主、单元为空 —— 特征是「测试跑得很慢、经常莫名其妙失败、没人愿意改」。

五、实战示例:优惠券折扣计算

用一个真实业务场景完整走 6 轮红绿重构。规则如下:

文本
折扣规则:- 订单金额 < 100:无折扣- 100 ≤ 金额 < 200:减 10- 金额 ≥ 200:减 30- VIP 用户:在满减基础上再打 95 折- 折后金额不低于 0

第 1 轮:零金额返回 0

🔴 红灯(JavaScript / Jest)

javascript
// discount.test.jsconst { calculateDiscount } = require('./discount')describe('calculateDiscount', () => {  test('零金额订单返回 0 折扣', () => {    expect(calculateDiscount(0)).toBe(0)  })})

运行失败:TypeError: calculateDiscount is not a function ✅ 有效红灯。

🟢 绿灯(最简实现)

javascript
// discount.jsfunction calculateDiscount(amount) {  return 0}module.exports = { calculateDiscount }

🔵 重构:无需重构。


第 2 轮:不满 100 无折扣

🔴 红灯

javascript
test('金额 99 时无折扣', () => {  expect(calculateDiscount(99)).toBe(0)})

注意:这个测试当场就通过了(因为上一轮硬编码 return 0)。 这没关系——它的价值是锁住行为,防止后续实现把 99 也算成满减。

🟢 绿灯:无需改动。


第 3 轮:满 100 减 10

🔴 红灯

javascript
test('金额 100 时减 10', () => {  expect(calculateDiscount(100)).toBe(10)})test('金额 150 时减 10', () => {  expect(calculateDiscount(150)).toBe(10)})

🟢 绿灯

javascript
function calculateDiscount(amount) {  if (amount >= 100) return 10  return 0}

第 4 轮:满 200 减 30(边界驱动)

🔴 红灯——重点测边界,不是测中心值:

javascript
test('金额 199.99 时仍减 10', () => {  expect(calculateDiscount(199.99)).toBe(10)})test('金额 200 时减 30', () => {  expect(calculateDiscount(200)).toBe(30)})

🟢 绿灯

javascript
function calculateDiscount(amount) {  if (amount >= 200) return 30  if (amount >= 100) return 10  return 0}

测试顺序很重要:先写 200 的测试会让实现变成 if (amount >= 200), 再写 100 的测试才补上第二档。若反过来,容易写出嵌套判断。


第 5 轮:VIP 额外 95 折

🔴 红灯

javascript
test('VIP 用户满 200 时:先减 30 再打 95 折', () => {  // (200 - 30) * 0.95 = 161.5  expect(calculateDiscount(200, { vip: true })).toBe(38.5)})

🟢 绿灯

javascript
function calculateDiscount(amount, options = {}) {  let discount = 0  if (amount >= 200) discount = 30  else if (amount >= 100) discount = 10  if (options.vip) {    // 满减后再打 95 折,等价于折扣额增加 5%    discount = amount - (amount - discount) * 0.95  }  return Math.round(discount * 100) / 100   // 避免浮点误差}

🔵 重构:把「满减规则」抽成独立函数,消除 if 链:

javascript
function baseDiscount(amount) {  if (amount >= 200) return 30  if (amount >= 100) return 10  return 0}function calculateDiscount(amount, options = {}) {  const base = baseDiscount(amount)  const final = options.vip ? (amount - base) * 0.95 : amount - base  return Math.round((amount - final) * 100) / 100}

重构后再跑一遍所有测试,全绿才继续。


第 6 轮:折后金额不低于 0

🔴 红灯

javascript
test('极小额订单折扣不为负', () => {  expect(calculateDiscount(0, { vip: true })).toBe(0)})test('折扣额永不超过订单金额', () => {  const discount = calculateDiscount(50, { vip: true })  expect(discount).toBeGreaterThanOrEqual(0)  expect(discount).toBeLessThanOrEqual(50)})

🟢 绿灯

javascript
function calculateDiscount(amount, options = {}) {  if (amount <= 0) return 0  const base = baseDiscount(amount)  const final = options.vip ? (amount - base) * 0.95 : amount - base  const diff = Math.round((amount - final) * 100) / 100  return Math.max(0, Math.min(diff, amount))}

最终测试文件全貌

javascript
describe('calculateDiscount', () => {  describe('基础满减档位', () => {    test('零金额返回 0', () => expect(calculateDiscount(0)).toBe(0))    test('99 元无折扣', () => expect(calculateDiscount(99)).toBe(0))    test('100 元减 10', () => expect(calculateDiscount(100)).toBe(10))    test('150 元减 10', () => expect(calculateDiscount(150)).toBe(10))    test('199.99 元仍减 10', () => expect(calculateDiscount(199.99)).toBe(10))    test('200 元减 30', () => expect(calculateDiscount(200)).toBe(30))  })  describe('VIP 折扣', () => {    test('VIP 满 200:38.5', () => expect(calculateDiscount(200, { vip: true })).toBe(38.5))    test('VIP 未满 100:5% 优惠', () => {      // 50 * 0.05 = 2.5      expect(calculateDiscount(50, { vip: true })).toBe(2.5)    })  })  describe('边界保护', () => {    test('零金额 VIP 不为负', () => expect(calculateDiscount(0, { vip: true })).toBe(0))    test('负数金额返回 0', () => expect(calculateDiscount(-10)).toBe(0))  })})

这个示例体现的 TDD 特征

现象 TDD 中的意义
先硬编码 return 0 绿灯阶段允许最简实现
第 4 轮先写 199.99 再写 200 边界测试驱动出正确的判断顺序
第 5 轮才发现需要 options 参数 接口由需求逐步演化,而非提前设计
重构时测试一行不改 重构的判据就是「测试不需要动」

六、多语言对照

同样一轮 TDD(测「满 100 减 10」),三种语言的写法:

JavaScript(Jest / Vitest)

javascript
test('金额 100 时减 10', () => {  expect(calculateDiscount(100)).toBe(10)})

Java(JUnit 5)

java
@Test@DisplayName("金额 100 时减 10")void shouldReturn10ForAmount100() {    assertEquals(10, DiscountCalculator.calculate(100));}

PHP(PHPUnit)

php
public function testAmount100Returns10(): void{    $this->assertSame(10, DiscountCalculator::calculate(100));}

框架速查

语言 主流程框架 断言 / Mock 生命周期
JavaScript Jest / Vitest / Mocha 内置 expect / jest.fn() beforeEach / afterAll
Java JUnit 5 AssertJ / Mockito @BeforeEach / @AfterAll
PHP PHPUnit / Pest 内置断言 / Mockery setUp() / tearDown()
Python Pytest 内置 assert / unittest.mock fixture

常用断言对照

语义 Jest JUnit 5 PHPUnit
相等 toBe(10) assertEquals(10, x) assertSame(10, $x)
近似相等 toBeCloseTo(38.5) assertEquals(38.5, x, 0.01) assertEqualsWithDelta()
真值 toBeTruthy() assertTrue(x) assertTrue($x)
抛异常 toThrow(Error) assertThrows(...) expectException(...)
集合包含 toContain(x) assertThat(list).contains(x) assertContains($x, $arr)
空值 toBeNull() assertNull(x) assertNull($x)

七、测试替身(Test Doubles)

7.1 五种替身

类型 作用 类比
Dummy 仅占位,从不使用 会议里凑人数的椅子
Stub 返回固定值,不做验证 自动回复机器人
Spy 记录调用情况(参数、次数) 录音笔
Mock 预设期望,验证交互是否发生 验收员
Fake 有真实逻辑的简化实现(如内存数据库) 模拟驾驶舱

7.2 什么时候该用替身

应该替身的外部依赖:

依赖 原因
数据库 慢、需环境、状态互相污染
HTTP 第三方接口 不稳定、要花钱、结果不可控
当前时间 不可重现(new Date() 每次不同)
随机数 / UUID 结果不可预测
文件系统 / 消息队列 慢、需清理

不应该替身的:

对象 原因
被测代码自己的业务逻辑 mock 掉它等于测试没测任何东西
纯函数工具类 直接调用更快更真实

7.3 时间与随机数:注入而非 mock

反面写法(难测):

javascript
function isExpired(coupon) {  return coupon.expireAt < new Date()   // 依赖系统时钟,测试无法固定}

正确写法(注入时钟):

javascript
function isExpired(coupon, now = new Date()) {  return coupon.expireAt < now}// 测试test('过期优惠券', () => {  const coupon = { expireAt: new Date('2026-01-01') }  expect(isExpired(coupon, new Date('2026-06-01'))).toBe(true)})

依赖注入 > Mock。可注入的参数比 mock 全局对象更清晰、更稳定。

7.4 各语言 Mock 示例

JavaScript(Jest)

javascript
jest.mock('./mailer')const { sendMail } = require('./mailer')test('注册成功后发送邮件', async () => {  sendMail.mockResolvedValue({ ok: true })  await register({ email: 'a@b.com' })  expect(sendMail).toHaveBeenCalledWith(    expect.objectContaining({ to: 'a@b.com' })  )})

Java(Mockito)

java
@ExtendWith(MockitoExtension.class)class UserServiceTest {    @Mock MailSender mailSender;    @InjectMocks UserService userService;    @Test    void shouldSendMailAfterRegister() {        userService.register("a@b.com");        verify(mailSender).send(argThat(m -> m.getTo().equals("a@b.com")));    }}

PHP(PHPUnit + Mockery)

php
public function testSendsMailAfterRegister(): void{    $mailer = Mockery::mock(Mailer::class);    $mailer->shouldReceive('send')->once()->with('a@b.com');    $service = new UserService($mailer);    $service->register('a@b.com');}

八、测试的命名与结构

8.1 AAA 三段式

每个测试内部按三段组织,用空行分隔:

javascript
test('VIP 用户满 200 减 30 后打 95 折', () => {  // Arrange(准备)  const amount = 200  const options = { vip: true }  // Act(执行)  const discount = calculateDiscount(amount, options)  // Assert(断言)  expect(discount).toBe(38.5)})

8.2 命名规范

好的测试名应描述行为,而非描述实现:

好的命名 坏的命名
金额为 200 时减 30 测试 calculateDiscount 函数的第二个 if
用户名重复时抛出业务异常 测试第 45 行
未登录用户访问返回 401 测试守卫

推荐格式:当<条件>时,应<预期结果>

javascript
describe('优惠券应用', () => {  test('当订单金额为 0 时,折扣为 0', ...)  test('当用户是 VIP 且金额满 200 时,折扣为 38.5', ...)})

8.3 目录组织

code
src/├── services/│   ├── discount.js│   └── discount.test.js        ← 同目录(推荐,就近维护)└── utils/    ├── date.js    └── date.test.js

或集中式(大型项目):

code
tests/├── unit/│   └── services/discount.test.js├── integration/└── e2e/

单元测试推荐与源码同目录——改代码时测试就在旁边,不易遗漏。


九、五个常见误区

误区 1:测实现,而非行为

javascript
// ❌ 测了内部实现:一旦重构(改用 Map)就失败,但行为没变test('使用数组存储', () => {  const store = new UserStore()  expect(store._users).toBeInstanceOf(Array)})// ✅ 测行为:重构后仍然通过test('添加用户后可以按 id 查到', () => {  const store = new UserStore()  store.add({ id: 1, name: 'A' })  expect(store.getById(1).name).toBe('A')})

判据:如果重构(不改行为)导致测试失败,说明测的是实现。

误区 2:过度 mock

javascript
// ❌ 全部 mock:这个测试什么都没验证jest.mock('./validator')jest.mock('./repository')test('创建订单', async () => {  validator.validate.mockReturnValue(true)  repository.save.mockResolvedValue({ id: 1 })  const result = await createOrder({})  expect(result.id).toBe(1)      // 只是在验证 mock 的返回值})

判据:mock 的数量超过断言的数量,通常说明测错了东西。

误区 3:测试脆弱

脆弱表现 原因 修复
单独跑通过、全量跑失败 测试间共享状态 每个测试独立初始化数据
换个机器就失败 依赖本地时区 / 路径 / 网络 固定时区、用相对路径、禁网
随机失败 依赖执行顺序、并发、时间 注入时钟,避免顺序依赖
断言字符串完全相等 输出格式微调就挂 断言关键字段,用 toContain / objectContaining

误区 4:只测 happy path

一个成熟测试套件应覆盖:

  • ✅ 正常路径(happy path)
  • ✅ 边界值(0、负数、最大值、临界点 99/100/199.99/200)
  • ✅ 异常路径(空值、类型错误、权限不足)
  • ✅ 状态异常(重复提交、并发冲突、资源不存在)

误区 5:追求 100% 覆盖率

覆盖率是发现遗漏的工具,不是质量指标。

覆盖率 真实情况可能
100% 每行都执行到了,但没有任何断言(expect 都没写)
80% 核心逻辑覆盖,边界清晰
40% 只测了 happy path

建议:核心业务逻辑追求 80%+;配置类、纯 UI、生成代码不强求。不要为了数字写无断言的测试。


十、TDD vs BDD vs SDD

实践 层次 核心 产物
SDD 需求 规格先行,约束解空间 spec.md / plan.md / tasks.md
BDD 行为 用业务语言描述场景 Gherkin(Given-When-Then)
TDD 代码 测试先行,驱动实现 单元测试代码

如何组合

文本
SDD:定边界 —— 要做什么、验收标准、错误码  ↓BDD:定场景 —— 用户视角的关键路径(可选,多角色协作时有用)  ↓TDD:定实现 —— 函数级红绿重构循环
层级 我该用哪个
新功能(跨多文件/跨端) SDD 先出规格
需求需与产品/业务对齐 中间加 BDD
具体函数、算法、边界逻辑 TDD 驱动实现

十一、老项目如何引入 TDD

从 0 覆盖率的遗留项目直接上 TDD 是不现实的。推荐渐进路径:

步骤 1:先写「特征测试」锁住现状

对即将改动的老代码,先写测试记录它当前的实际行为(哪怕这行为看起来是 bug):

javascript
// 现状可能是错的,但先锁住,避免重构时意外改变行为test('[特征测试] 购物车满减当前实现:99 元不优惠', () => {  expect(legacyCart.total(99)).toBe(99)})

步骤 2:只在改动时补测试(童子军原则)

离开营地时,让它比你来时更干净。

  • 不改动的老代码:暂不写测试
  • 要改动的老代码:先补测试锁行为,再动手改
  • 新写的代码:一律 TDD

步骤 3:优先补关键路径

优先级 模块
高 支付、订单、金额计算、权限
中 用户注册登录、核心业务流程
低 展示型接口、配置读取、后台管理

步骤 4:把测试纳入 CI

yaml
# 示例:CI 中强制测试通过 + 覆盖率不下降- run: npm test -- --coverage- run: npx coverage-check --min 70   # 门槛设在当前值,只许升不许降

覆盖率门槛设在当前值,而不是一步到位 80%。让团队先习惯「只能升不能降」。

渐进路径总表

阶段 目标 时间
1 CI 跑通全部现有测试 第 1 周
2 关键路径补特征测试 第 2-4 周
3 新代码强制 TDD 持续
4 覆盖率门槛逐步抬升 每月 +5%
5 重构老模块时补测试 常态化

十二、常见问题(FAQ)

Q1:没时间写测试怎么办?

时间成本是前置的,收益是后置的。真实账目:

项 后补测试 TDD
写功能耗时 快 20% 慢 20%(多花在写测试)
调试耗时 多 50%+ 少(断言直接定位)
回归 Bug 多 少
重构信心 低 高

短期看 TDD 慢,一个月的项目周期内通常持平,三个月以上明显划算。项目越长期、需求变更越频繁,TDD 收益越大。

Q2:前端怎么 TDD?

分类型处理:

代码类型 建议
纯函数 / 工具函数 ✅ 标准 TDD
状态管理(Pinia/Redux store) ✅ 测 action 与 getter
组件逻辑(props → 渲染) ⚠️ 用 Testing Library 测用户可见行为,不测 DOM 结构
样式 / 布局 ❌ 不 TDD,靠视觉回归或人工验收
交互流程 ⚠️ 用 E2E(Playwright)覆盖关键路径

Q3:测试代码也要维护,成本不是更高吗?

测试维护成本主要来自脆弱测试,而非测试本身。减少成本的做法:

  • 测行为不测实现(重构不用改测试)
  • 少用 mock,多用依赖注入
  • 断言关键字段,不做全字符串比对
  • 一个测试只断言一件事

Q4:UI 和数据库相关代码,TDD 起来很别扭?

因为它们属于集成层,不适合严格的 TDD 循环。做法是:业务逻辑抽到纯函数/服务层做 TDD,集成层用少量集成测试兜底。这也是 TDD 推动良好架构(逻辑与 IO 分离)的体现。

Q5:团队没人写测试,怎么推动?

从三件事入手:

  1. 不要求全量:只要求「新代码 + 改动的老代码」有测试
  2. CI 卡口:测试不过不许合并(比口头要求有效得多)
  3. 用 Bug 反推:每出现一个线上 Bug,先补一个复现该 Bug 的测试,再修

Q6:测试要写到多细?

粒度 建议
每个 public 方法 ✅ 至少 happy path + 一个异常路径
每个分支 ✅ 边界值必须覆盖
每个私有方法 ❌ 不单独测,通过 public 接口间接覆盖
每行代码 ❌ 不为覆盖率而测

十三、最佳实践清单

写测试时

  • 测试名描述行为,不是描述实现
  • 一个测试只验证一个行为(失败时能一眼定位)
  • 遵循 AAA 三段式,段间空行分隔
  • 覆盖边界值(0、负数、临界点)与异常路径
  • 断言关键字段,避免全量字符串比对

设计时

  • 业务逻辑与 IO 分离(便于单测)
  • 时间、随机数通过参数注入,而非 mock 全局
  • 只 mock 外部依赖,不 mock 自己的业务逻辑
  • 函数尽量纯(同输入同输出)

维护时

  • 重构导致测试失败 = 测试测错了实现,应改测试
  • 每个线上 Bug 先补复现测试,再修
  • 测试单独跑与全量跑结果一致(无共享状态)
  • 测试执行快(单元测试整体 < 10 秒)

落地时

  • CI 强制测试通过才能合并
  • 覆盖率门槛设在当前值,只升不降
  • 老代码只在改动时补测试(童子军原则)
  • 优先覆盖高价值模块(支付、权限、金额)

十四、总结

TDD 的本质不是「测试」,而是一种控制节奏的开发方式:

  1. 红-绿-重构是唯一必须掌握的循环——先看它失败,再让它通过,最后在安全网下改结构。
  2. 测试驱动的是设计,不只是验证。写测试的过程逼你想清楚接口、边界和异常。
  3. TDD 不是全项目通吃的——它是逻辑密集型代码的工具:支付、金额、权限、算法值得;纯 UI、一次性脚本不值得。
  4. 覆盖率不是目标,行为覆盖才是。100% 覆盖率可能一行断言都没有。

与 SDD 的关系可以这样理解:

文本
SDD 保证「做对的事」(规格与验收标准)TDD 保证「事做对了」(每一段逻辑都有测试兜底)

两者结合,就是你在 AI 协作开发中最需要的那层确定性:规格说清楚要什么,测试证明真的做到了。


完

前端、后端、部署、客服都是我一个人。写这里的东西都是真踩过的坑,不是抄来的教程。