mcpField
T
tdd

tdd

github.com/udecode测试与质量1.2k237 次安装1 天前更新
代码仓库

关于 tdd

tdd 是一项指导开发者采用测试驱动开发的技能,强调通过公开接口验证用户可观察的行为。它提供逐项执行 RED→GREEN→重构的流程,并涵盖边界模拟与类型测试。

主要功能

  1. 01通过公开接口测试行为
  2. 02逐项执行 RED→GREEN 循环
  3. 03仅在系统边界使用模拟
  4. 04设计便于测试的接口
  5. 05提供编译期类型断言指导

适用场景

  1. 01为新功能先编写行为测试
  2. 02为复杂业务逻辑逐步实现测试
  3. 03修复缺陷前建立复现测试
  4. 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),
};

面向可测试性的接口设计

  1. 接收依赖,而不是创建依赖

    // Testable
    function processOrder(order, paymentGateway) {}
    
    // Hard to test
    function processOrder(order) {
      const gateway = new StripeGateway();
    }
    
  2. 返回结果,而不是产生副作用

    // Testable
    function calculateDiscount(cart): Discount {}
    
    // Hard to test
    function applyDiscount(cart): void {
      cart.total -= discount;
    }
    
  3. 精简接口面——方法越少,需要的测试就越少;参数越少,测试设置就越简单

深模块(源自《软件设计的哲学》):小接口 + 大量实现。设计时请思考:能否减少方法?简化参数?将更多复杂性隐藏在内部?

反模式:横向切片

不要先写完所有测试,再写所有实现。 这就是“横向切片”——把 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。如果编译通过,测试就通过。

评论

支持 Markdown