关于 tdd
tdd 是一项指导开发者采用测试驱动开发的技能,强调通过公开接口验证用户可观察的行为。它提供逐项执行 RED→GREEN→重构的流程,并涵盖边界模拟与类型测试。
主要功能
- 01通过公开接口测试行为
- 02逐项执行 RED→GREEN 循环
- 03仅在系统边界使用模拟
- 04设计便于测试的接口
- 05提供编译期类型断言指导
适用场景
- 01为新功能先编写行为测试
- 02为复杂业务逻辑逐步实现测试
- 03修复缺陷前建立复现测试
- 04检查泛型与复杂类型约束
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ udecode/ dotai --skill tdd安装后在 Claude Code 中输入/tdd确认已加载。
详细介绍
测试驱动开发
理念
核心原则:测试应通过公共接口验证行为,而不是实现细节。代码可以彻底改变;测试不应改变。
好的测试采用集成测试风格:通过公共 API 执行真实代码路径。它们描述系统_做什么_,而不是_怎么做_。好的测试读起来就像一份规范——“用户可以使用有效购物车结账”能清楚地说明系统具备什么能力。这类测试经得起重构,因为它们不关心内部结构。
// GOOD: Tests observable behavior
test("user can checkout with valid cart", async () => {
const cart = createCart();
cart.add(product);
const result = await checkout(cart, paymentMethod);
expect(result.status).toBe("confirmed");
});
// GOOD: Verifies through interface
test("createUser makes user retrievable", async () => {
const user = await createUser({ name: "Alice" });
const retrieved = await getUser(user.id);
expect(retrieved.name).toBe("Alice");
});
好的测试具备以下特点:
- 测试用户/调用方关心的行为
- 只使用公共 API
- 经得起内部重构
- 描述做什么,而不是怎么做
- 每个测试只有一个逻辑断言
糟糕的测试与实现耦合。它们模拟内部协作者、测试私有方法,或通过外部手段验证(例如直接查询数据库,而不是使用接口)。警示信号是:重构时测试会失败,但行为并未改变。
// BAD: Tests implementation details
test("checkout calls paymentService.process", async () => {
const mockPayment = jest.mock(paymentService);
await checkout(cart, payment);
expect(mockPayment.process).toHaveBeenCalledWith(cart.total);
});
// BAD: Bypasses interface to verify
test("createUser saves to database", async () => {
await createUser({ name: "Alice" });
const row = await db.query("SELECT * FROM users WHERE name = ?", ["Alice"]);
expect(row).toBeDefined();
});
危险信号:
- 模拟内部协作者
- 测试私有方法
- 断言调用次数/顺序
- 行为不变但重构会导致测试失败
- 测试名称描述怎么做,而不是做什么
尽量在实现之前编写测试。如果你已经写好了代码,可以考虑从头开始编写测试,而不是事后补上——事后编写的测试往往是在验证你已经构建的内容,而不是实际需求。
模拟
只在系统边界处进行模拟:
- 外部 API(支付、电子邮件等)
- 数据库(有时需要——优先使用测试数据库)
- 时间/随机性
- 文件系统(有时需要)
不要模拟:
- 你自己的类/模块
- 内部协作者
- 任何由你控制的内容
使用依赖注入——传入外部依赖,而不是在内部创建:
// Easy to mock
function processPayment(order, paymentClient) {
return paymentClient.charge(order.total);
}
// Hard to mock
function processPayment(order) {
const client = new StripeClient(process.env.STRIPE_KEY);
return client.charge(order.total);
}
优先采用 SDK 风格的接口——为每种外部操作提供专用函数:
// GOOD: Each function is independently mockable
const api = {
getUser: (id) => fetch(`/users/${id}`),
getOrders: (userId) => fetch(`/users/${userId}/orders`),
createOrder: (data) => fetch("/orders", { method: "POST", body: data }),
};
// BAD: Mocking requires conditional logic inside the mock
const api = {
fetch: (endpoint, options) => fetch(endpoint, options),
};
面向可测试性的接口设计
-
接收依赖,而不是创建依赖
// Testable function processOrder(order, paymentGateway) {} // Hard to test function processOrder(order) { const gateway = new StripeGateway(); } -
返回结果,而不是产生副作用
// Testable function calculateDiscount(cart): Discount {} // Hard to test function applyDiscount(cart): void { cart.total -= discount; } -
精简接口面——方法越少,需要的测试就越少;参数越少,测试设置就越简单
深模块(源自《软件设计的哲学》):小接口 + 大量实现。设计时请思考:能否减少方法?简化参数?将更多复杂性隐藏在内部?
反模式:横向切片
不要先写完所有测试,再写所有实现。 这就是“横向切片”——把 RED 阶段当作“编写所有测试”,把 GREEN 阶段当作“编写所有代码”。
这会产生糟糕的测试:
- 批量编写的测试验证的是_设想中的_行为,而不是_实际的_行为
- 最终你测试的是事物的_形状_(数据结构、函数签名),而不是面向用户的行为
- 测试会对真实变化不敏感——行为失效时测试仍然通过,行为正常时测试反而失败
- 你超前于自己的理解,在弄清实现之前就承诺了测试结构
正确方法:通过示踪弹进行纵向切片。一个测试 → 一个实现 → 重复。每个测试都根据上一轮学到的内容进行调整。
WRONG (horizontal):
RED: test1, test2, test3, test4, test5
GREEN: impl1, impl2, impl3, impl4, impl5
RIGHT (vertical):
RED→GREEN: test1→impl1
RED→GREEN: test2→impl2
RED→GREEN: test3→impl3
...
工作流
1. 规划
编写任何代码之前:
- 与用户确认需要哪些接口变更
- 与用户确认要测试哪些行为(确定优先级)
- 找出构建深模块的机会(小接口、深实现)
- 设计便于测试的接口
- 列出要测试的行为(而不是实现步骤)
- 征得用户对计划的认可
请询问:“公共接口应该是什么样的?哪些行为最值得优先测试?”
你无法测试所有内容。 与用户确认哪些行为最重要。把测试精力集中在关键路径和复杂逻辑上,而不是覆盖所有可能的边缘情况。
2. 示踪弹
编写一个测试,只验证系统的一项功能:
RED: Write test → run test → confirm it FAILS correctly
GREEN: Write minimal code → run test → confirm it PASSES
- 测试立即通过?说明你测试的是现有行为。修正测试。
- 测试报错(而非断言失败)?先修复错误——报错和失败不是一回事。
这就是你的示踪弹,用来证明整个端到端流程可行。
3. 增量循环
针对每项剩余行为:
RED: Write next test → run test → confirm it FAILS correctly
GREEN: Write minimal code → run test → confirm it PASSES
规则:
- 一次只写一个测试
- 只编写足以通过当前测试的代码
- 不要提前考虑未来的测试
- 让测试聚焦于可观察的行为
4. 重构
所有测试通过后,寻找适合重构的地方:
- 提取重复代码
- 深化模块(将复杂性隐藏在简单接口之后)
- 在合适时应用 SOLID 原则
- 思考新增代码揭示了现有代码的哪些问题
- 每次重构后运行测试
重构候选项:重复代码 → 提取函数/类;长方法 → 拆分为私有辅助方法;浅层模块 → 合并或深化;特性嫉妒 → 将逻辑移到数据所在的位置;基本类型偏执 → 引入值对象。
RED 状态下绝不重构。 先达到 GREEN。
每轮检查清单
[ ] Test describes behavior, not implementation
[ ] Test uses public interface only
[ ] Test would survive internal refactor
[ ] Code is minimal for this test
[ ] No speculative features added
[ ] Watched test fail before writing code
[ ] Failure was for expected reason (missing feature, not typo)
[ ] All other tests still pass
修复 Bug 示例
TDD 同样适用于修复 Bug——先编写能复现 Bug 的测试。
# Bug: empty email passes validation
RED: test("rejects empty email", () => {
const result = validateEmail("");
expect(result.valid).toBe(false);
});
→ Run test → FAILS (empty string passes validation) ✓
GREEN: Add check: if (!email || !email.includes("@")) return { valid: false }
→ Run test → PASSES ✓
Verify all other validation tests still pass.
类型测试
编译时类型断言。不在运行时执行——只需运行 bun typecheck。可捕获运行时测试无法发现的泛型、条件类型和类型约束回归。
适用场景: 泛型 API、工具类型、复杂类型推断、映射/条件类型,以及确保无效用法会报错。不适用: string 属性接受 string 这类简单情况。
工具
搜索导出 Expect 和 Equal 的文件。如果没有,就创建一个:
export function Expect<T extends true>() {}
export type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <
T
>() => T extends Y ? 1 : 2
? true
: false;
export type Not<T extends boolean> = T extends true ? false : true;
export type IsAny<T> = 0 extends 1 & T ? true : false;
export type IsNever<T> = [T] extends [never] ? true : false;
Expect<T extends true>— 编译错误 = 测试失败Equal<X, Y>— 精确的类型相等性(可防止any扩宽)Not、IsAny、IsNever— 边界情况保护(any/never会破坏简单比较)
正向断言
import { Expect, Equal, Not, IsAny } from "./utils";
// Block scope each test to avoid name collisions
{
type Result = ReturnType<typeof myGenericFn<SomeInput>>;
Expect<Equal<Result, { id: string; name: string }>>;
Expect<Not<IsAny<Result>>>;
}
负向测试
@ts-expect-error 必须写在错误所在行的紧邻上一行。始终说明原因。未使用的指令 = 测试失败(缺少约束)。
// ✅ directive on line immediately before error
doSomething({
// @ts-expect-error - name must be string
name: 123,
});
// ❌ directive too far from error
doSomething({
// @ts-expect-error - name must be string
...defaults,
name: 123,
});
提示
declare const用于声明没有运行时值的模拟值:declare const ctx: SomeCtx;type _name = Expect<...>用于只需要类型级断言的场景(无需在运行时调用Expect())- 在仅包含类型的文件顶部使用
/* biome-ignore-all lint */,以抑制未使用变量警告
运行 bun typecheck。如果编译通过,测试就通过。