mcpField
T
tdd:test-driven-development

tdd:test-driven-development

github.com/neolabhq测试与质量1.8k501 次安装9 周前更新
代码仓库

关于 tdd:test-driven-development

tdd:test-driven-development 是一项指导开发者先写失败测试、再编写最少实现并重构的编码技能。它强调确认测试先按预期失败,并避免只验证模拟对象的测试。

主要功能

  1. 01采用 Red-Green-Refactor 循环
  2. 02要求先确认测试失败
  3. 03编写通过测试所需的最少代码
  4. 04验证真实行为而非模拟行为
  5. 05提供 TDD 反模式指引

适用场景

  1. 01以测试优先开发新功能
  2. 02复现并修复程序错误
  3. 03借助回归测试进行重构

安装

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

$npx skills add https://github.com/neolabhq/context-engineering-kit --skill tdd:test-driven-development

安装后在 Claude Code 中输入/tdd:test-driven-development确认已加载。

详细介绍

测试驱动开发(TDD)

概述

先写测试。看着它失败。编写最少的代码使其通过。

核心原则: 如果你没有亲眼看着测试失败,就不知道它是否测试了正确的内容。

违反规则的字面要求,就是违背规则的精神。

适用场景

始终适用:

  • 新功能
  • 错误修复
  • 重构
  • 行为变更

例外情况(请询问你的人工搭档):

  • 一次性原型
  • 生成的代码
  • 配置文件

在想“这次就跳过 TDD”?停下。这是在为自己找借口。

铁律

NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST

先写了代码再写测试?删掉它。从头开始。

没有例外:

  • 不要把它留作“参考”
  • 不要在写测试时对它进行“改编”
  • 不要看它
  • 删除就是删除

从测试出发重新实现。就这样。

红-绿-重构

digraph tdd_cycle {
    rankdir=LR;
    red [label="RED\nWrite failing test", shape=box, style=filled, fillcolor="#ffcccc"];
    verify_red [label="Verify fails\ncorrectly", shape=diamond];
    green [label="GREEN\nMinimal code", shape=box, style=filled, fillcolor="#ccffcc"];
    verify_green [label="Verify passes\nAll green", shape=diamond];
    refactor [label="REFACTOR\nClean up", shape=box, style=filled, fillcolor="#ccccff"];
    next [label="Next", shape=ellipse];

    red -> verify_red;
    verify_red -> green [label="yes"];
    verify_red -> red [label="wrong\nfailure"];
    green -> verify_green;
    verify_green -> refactor [label="yes"];
    verify_green -> green [label="no"];
    refactor -> verify_green [label="stay\ngreen"];
    verify_green -> next;
    next -> red;
}
RED - 编写失败的测试

编写一个最简测试,展示应该发生什么。

```typescript test('retries failed operations 3 times', async () => { let attempts = 0; const operation = () => { attempts++; if (attempts < 3) throw new Error('fail'); return 'success'; };

const result = await retryOperation(operation);

expect(result).toBe('success'); expect(attempts).toBe(3); });

名称清晰,测试真实行为,只测试一件事
</Good>

<Bad>
```typescript
test('retry works', async () => {
  const mock = jest.fn()
    .mockRejectedValueOnce(new Error())
    .mockRejectedValueOnce(new Error())
    .mockResolvedValueOnce('success');
  await retryOperation(mock);
  expect(mock).toHaveBeenCalledTimes(3);
});

名称含糊,测试的是模拟对象而非代码

要求:

  • 一种行为
  • 名称清晰
  • 真实代码(除非无法避免,否则不要使用模拟对象)
验证 RED - 看着它失败

必须执行。绝不可跳过。

npm test path/to/test.test.ts

确认:

  • 测试失败(不是报错)
  • 失败消息符合预期
  • 失败原因是缺少功能(而不是拼写错误)

测试通过了? 你测试的是现有行为。修正测试。

测试报错了? 修复错误,然后重新运行,直到它正确失败。

GREEN - 编写最少的代码

编写最简单的代码,让测试通过。

```typescript async function retryOperation(fn: () => Promise): Promise { for (let i = 0; i < 3; i++) { try { return await fn(); } catch (e) { if (i === 2) throw e; } } throw new Error('unreachable'); } ``` 足以通过即可 ```typescript async function retryOperation( fn: () => Promise, options?: { maxRetries?: number; backoff?: 'linear' | 'exponential'; onRetry?: (attempt: number) => void; } ): Promise { // YAGNI } ``` 过度设计

不要添加功能、重构其他代码,也不要超出测试范围进行“改进”。

验证 GREEN - 看着它通过

必须执行。

npm test path/to/test.test.ts

确认:

  • 测试通过
  • 其他测试仍然通过
  • 输出干净(没有错误或警告)

测试失败了? 修复代码,不要改测试。

其他测试失败了? 立即修复。

REFACTOR - 清理代码

只有通过后才能执行:

  • 消除重复
  • 改进命名
  • 提取辅助函数

保持测试通过。不要添加行为。

重复

为下一个功能编写下一个失败的测试。

好的测试

质量好不好
最简只测试一件事。名称里有“和”?拆分它。test('validates email and domain and whitespace')
清晰名称描述行为test('test1')
表达意图展示期望的 API模糊代码应该执行的操作

为什么顺序很重要

“我之后再写测试来验证它能否正常工作”

代码写完后再写的测试会立即通过。立即通过证明不了任何事:

  • 可能测错了内容
  • 可能测的是实现,而不是行为
  • 可能漏掉你忘记考虑的边界情况
  • 你从未看到测试捕获这个 Bug

先写测试会迫使你看到测试失败,从而证明它确实测到了某些内容。

“我已经手动测试过所有边界情况了”

手动测试是临时性的。你以为自己测试了所有情况,但实际上:

  • 没有记录你测试过什么
  • 代码变更后无法重新运行
  • 有压力时很容易忘记测试某些情况
  • “我试过时能用” ≠ 全面验证

自动化测试是系统性的。每次运行的方式都相同。

“删掉 X 小时的工作太浪费了”

这是沉没成本谬误。时间已经花掉了。现在你可以选择:

  • 删除并用 TDD 重写(再花 X 小时,但信心十足)
  • 保留代码并事后补上测试(30 分钟,但信心不足,而且可能有 Bug)

真正的“浪费”,是保留你无法信任的代码。没有真正测试的可运行代码就是技术债务。

“TDD 太教条了,务实就意味着灵活变通”

TDD 就是务实的做法:

  • 在提交前发现 Bug(比事后调试更快)
  • 防止回归(测试能立即发现问题)
  • 记录行为(测试展示如何使用代码)
  • 支持重构(可以自由修改,测试会发现问题)

所谓“务实”的捷径 = 在生产环境中调试 = 更慢。

“事后写测试也能达到同样的目标——关键是精神,而不是仪式”

不对。事后写的测试回答“这段代码做了什么?”,先写的测试回答“这段代码应该做什么?”

事后写的测试会受到实现的影响。你测的是自己构建的内容,而不是需求。你验证的是记得的边界情况,而不是发现的边界情况。

先写测试会迫使你在实现前发现边界情况。事后写的测试只能验证你是否记得所有情况(你并没有)。

事后花 30 分钟写测试 ≠ TDD。你能获得覆盖率,却无法证明测试有效。

常见的自我辩解

借口现实
“太简单,不值得测试”简单的代码也会出问题。测试只需 30 秒。
“我之后再测试”测试立即通过证明不了任何事。
“事后写测试也能达到同样的目标”事后写测试 = “这段代码做了什么?”先写测试 = “这段代码应该做什么?”
“我已经手动测试过了”临时测试 ≠ 系统测试。没有记录,也无法重新运行。
“删掉 X 小时的工作太浪费了”这是沉没成本谬误。保留未经验证的代码就是技术债务。
“留着作参考,先写测试”你会基于它调整代码。这就是事后测试。删除就要彻底删除。
“我需要先探索一下”没问题。舍弃探索代码,然后从 TDD 开始。
“测试很难写 = 设计不清晰”听听测试的反馈。难以测试 = 难以使用。
“TDD 会拖慢我”TDD 比调试更快。务实 = 先写测试。
“手动测试更快”手动测试无法证明边界情况有效。每次修改后你都得重新测试。
“现有代码没有测试”你正在改进它。为现有代码添加测试。

危险信号——停止并重新开始

  • 先写代码,再写测试
  • 实现完成后才写测试
  • 测试立即通过
  • 说不清测试为什么失败
  • 测试“以后再加”
  • 用“就这一次”来合理化
  • “我已经手动测试过了”
  • “事后写测试也能达到同样的目的”
  • “关键是精神,而不是仪式”
  • “留着作参考”或“基于现有代码调整”
  • “已经花了 X 小时,现在删掉太浪费”
  • “TDD 太教条了,我这是在务实”
  • “这次情况不一样,因为……”

这些都意味着:删除代码。用 TDD 重新开始。

示例:修复 Bug

**Bug:**接受空电子邮件地址

RED

test('rejects empty email', async () => {
  const result = await submitForm({ email: '' });
  expect(result.error).toBe('Email required');
});

验证 RED

$ npm test
FAIL: expected 'Email required', got undefined

GREEN

function submitForm(data: FormData) {
  if (!data.email?.trim()) {
    return { error: 'Email required' };
  }
  // ...
}

验证 GREEN

$ npm test
PASS

重构 如有需要,为多个字段提取验证逻辑。

验证清单

标记工作完成前:

  • 每个新函数/方法都有测试
  • 实现前观察每个测试失败
  • 每个测试都因预期原因失败(功能缺失,而非拼写错误)
  • 编写最少的代码,让每个测试通过
  • 所有测试均通过
  • 输出干净(无错误、无警告)
  • 测试使用真实代码(仅在无法避免时使用模拟)
  • 覆盖边界情况和错误

无法勾选所有选项?说明你跳过了 TDD。重新开始。

遇到阻碍时

问题解决方案
不知道如何测试写出期望的 API。先写断言。询问你的人类伙伴。
测试太复杂设计太复杂。简化接口。
必须模拟所有内容代码耦合过紧。使用依赖注入。
测试设置过于庞大提取辅助函数。还是很复杂?简化设计。

调试集成问题

发现 Bug?编写一个能复现问题的失败测试。遵循 TDD 循环。测试既能证明修复有效,也能防止回归。

修复 Bug 时绝不能不写测试。

最终规则

Production code → test exists and failed first
Otherwise → not TDD

未经你的人类伙伴许可,不得例外。


测试反模式

概述

测试必须验证真实行为,而不是模拟行为。模拟是用于隔离的手段,不是被测试的对象。

核心原则: 测试代码的行为,而不是模拟对象的行为。

遵循严格的 TDD 可以避免这些反模式。

铁律

1. NEVER test mock behavior
2. NEVER add test-only methods to production classes
3. NEVER mock without understanding dependencies

反模式 1:测试模拟对象的行为

违规做法:

// ❌ BAD: Testing that the mock exists
test('renders sidebar', () => {
  render(<Page />);
  expect(screen.getByTestId('sidebar-mock')).toBeInTheDocument();
});

错误原因:

  • 你验证的是模拟对象是否正常,而不是组件是否正常
  • 有模拟对象时测试通过,没有时则失败
  • 无法让你了解真实行为

你的人类伙伴的纠正意见:“我们是在测试模拟对象的行为吗?”

修复方法:

// ✅ GOOD: Test real component or don't mock it
test('renders sidebar', () => {
  render(<Page />);  // Don't mock sidebar
  expect(screen.getByRole('navigation')).toBeInTheDocument();
});

// OR if sidebar must be mocked for isolation:
// Don't assert on the mock - test Page's behavior with sidebar present
Gate 函数
BEFORE asserting on any mock element:
  Ask: "Am I testing real component behavior or just mock existence?"

  IF testing mock existence:
    STOP - Delete the assertion or unmock the component

  Test real behavior instead

反模式 2:在生产代码中添加仅用于测试的方法

违规做法:

// ❌ BAD: destroy() only used in tests
class Session {
  async destroy() {  // Looks like production API!
    await this._workspaceManager?.destroyWorkspace(this.id);
    // ... cleanup
  }
}

// In tests
afterEach(() => session.destroy());

错误原因:

  • 生产类中混入了仅供测试使用的代码
  • 如果在生产环境中被意外调用,会很危险
  • 违反 YAGNI 和关注点分离原则
  • 混淆对象生命周期与实体生命周期

修复方法:

// ✅ GOOD: Test utilities handle test cleanup
// Session has no destroy() - it's stateless in production

// In test-utils/
export async function cleanupSession(session: Session) {
  const workspace = session.getWorkspaceInfo();
  if (workspace) {
    await workspaceManager.destroyWorkspace(workspace.id);
  }
}

// In tests
afterEach(() => cleanupSession(session));
Gate 函数
BEFORE adding any method to production class:
  Ask: "Is this only used by tests?"

  IF yes:
    STOP - Don't add it
    Put it in test utilities instead

  Ask: "Does this class own this resource's lifecycle?"

  IF no:
    STOP - Wrong class for this method

反模式 3:不了解情况就使用模拟

违规做法:

// ❌ BAD: Mock breaks test logic
test('detects duplicate server', () => {
  // Mock prevents config write that test depends on!
  vi.mock('ToolCatalog', () => ({
    discoverAndCacheTools: vi.fn().mockResolvedValue(undefined)
  }));

  await addServer(config);
  await addServer(config);  // Should throw - but won't!
});

错误原因:

  • 被模拟的方法有测试所依赖的副作用(写入配置)
  • 为了“保险”而过度使用模拟会破坏实际行为
  • 测试可能因错误原因通过,也可能莫名失败

修复方法:

// ✅ GOOD: Mock at correct level
test('detects duplicate server', () => {
  // Mock the slow part, preserve behavior test needs
  vi.mock('MCPServerManager'); // Just mock slow server startup

  await addServer(config);  // Config written
  await addServer(config);  // Duplicate detected ✓
});
Gate 函数
BEFORE mocking any method:
  STOP - Don't mock yet

  1. Ask: "What side effects does the real method have?"
  2. Ask: "Does this test depend on any of those side effects?"
  3. Ask: "Do I fully understand what this test needs?"

  IF depends on side effects:
    Mock at lower level (the actual slow/external operation)
    OR use test doubles that preserve necessary behavior
    NOT the high-level method the test depends on

  IF unsure what test depends on:
    Run test with real implementation FIRST
    Observe what actually needs to happen
    THEN add minimal mocking at the right level

  Red flags:
    - "I'll mock this to be safe"
    - "This might be slow, better mock it"
    - Mocking without understanding the dependency chain

反模式 4:模拟对象不完整

违规做法:

// ❌ BAD: Partial mock - only fields you think you need
const mockResponse = {
  status: 'success',
  data: { userId: '123', name: 'Alice' }
  // Missing: metadata that downstream code uses
};

// Later: breaks when code accesses response.metadata.requestId

错误原因:

  • 不完整的模拟对象会掩盖结构假设——你只模拟了已知的字段
  • 下游代码可能依赖你未包含的字段——导致静默失败
  • 测试通过但集成失败——模拟对象不完整,真实 API 却完整
  • 产生虚假的信心——测试无法证明真实行为

铁律: 模拟现实中的完整数据结构,而不只是当前测试直接用到的字段。

修复方法:

// ✅ GOOD: Mirror real API completeness
const mockResponse = {
  status: 'success',
  data: { userId: '123', name: 'Alice' },
  metadata: { requestId: 'req-789', timestamp: 1234567890 }
  // All fields real API returns
};
Gate 函数
BEFORE creating mock responses:
  Check: "What fields does the real API response contain?"

  Actions:
    1. Examine actual API response from docs/examples
    2. Include ALL fields system might consume downstream
    3. Verify mock matches real response schema completely

  Critical:
    If you're creating a mock, you must understand the ENTIRE structure
    Partial mocks fail silently when code depends on omitted fields

  If uncertain: Include all documented fields

反模式 5:把集成测试当作事后补充

违规做法:

✅ Implementation complete
❌ No tests written
"Ready for testing"

错误原因:

  • 测试是实现的一部分,不是可有可无的后续工作
  • TDD 本可以及时发现这个问题
  • 没有测试,就不能声称工作已完成

修复方法:

TDD cycle:
1. Write failing test
2. Implement to pass
3. Refactor
4. THEN claim complete

模拟变得过于复杂时

警示信号:

  • 模拟设置比测试逻辑还长
  • 为了让测试通过而模拟一切
  • 模拟对象缺少真实组件具有的方法
  • 模拟对象一变,测试就会失败

你的人类伙伴会问:“这里真的需要使用模拟对象吗?”

可以考虑: 使用真实组件进行集成测试,往往比复杂的模拟对象更简单

TDD 可避免这些反模式

TDD 的益处:

  1. 先编写测试 → 促使你思考实际要测试什么
  2. 观察测试失败 → 确认测试覆盖的是真实行为,而不是模拟对象
  3. 最小化实现 → 不会逐渐加入仅供测试使用的方法
  4. 使用真实依赖项 → 在创建模拟对象之前,先了解测试实际需要什么

如果你在测试模拟对象的行为,就违背了 TDD——你在没有先观察测试针对真实代码失败的情况下就添加了模拟对象。

快速参考

反模式修复方法
对模拟对象的元素进行断言测试真实组件,或取消模拟
在生产代码中添加仅供测试使用的方法移至测试工具中
未了解情况就创建模拟对象先了解依赖项,尽可能少地创建模拟对象
不完整的模拟对象完整复现真实 API
事后才考虑测试采用 TDD——先编写测试
过于复杂的模拟对象考虑集成测试

危险信号

  • 断言检查 *-mock 测试 ID
  • 某些方法只在测试文件中调用
  • 模拟设置占测试内容的 50% 以上
  • 移除模拟对象后测试失败
  • 无法解释为什么需要模拟对象
  • “以防万一”而创建模拟对象

要点

模拟对象是用于隔离的工具,不是测试对象。

如果 TDD 让你发现自己在测试模拟对象的行为,那就说明方向错了。

修正方法:测试真实行为,或质疑自己为什么要创建模拟对象。

评论

支持 Markdown