测试模式Skill testing-patterns

测试模式是一种软件测试技能,用于通过依赖注入、测试替身、安全重构等技术有效测试代码。它帮助开发者在没有测试的情况下安全修改代码,添加测试到现有代码,并通过特征化测试理解不熟悉的代码。关键词:测试模式、依赖注入、测试替身、安全重构、特征化测试、软件测试。

测试 0 次安装 0 次浏览 更新于 3/19/2026

name: testing-patterns description: 有效测试代码的模式。当为可测试性打破依赖、向现有代码添加测试、通过特征化测试理解不熟悉的代码或决定如何结构化测试时使用。涵盖接缝、依赖注入、测试替身和来自Michael Feathers的安全重构技术。

测试模式

核心洞察:没有测试的代码很难安全地更改。测试困境:要安全地更改代码,你需要测试,但要添加测试,你通常需要先更改代码。

接缝模型

接缝是一个可以在不编辑源文件的情况下更改行为的地方。

每个接缝都有一个启用点 - 决定使用哪种行为的地方。

接缝类型(从好到坏)

对象接缝(在面向对象语言中首选):

// 原始 - 硬依赖
class PaymentProcessor {
  process(amount: number) {
    const gateway = new StripeGateway(); // 不可测试
    return gateway.charge(amount);
  }
}

// 带接缝 - 可注入依赖
class PaymentProcessor {
  constructor(private gateway: PaymentGateway = new StripeGateway()) {}

  process(amount: number) {
    return this.gateway.charge(amount); // 启用点:构造函数
  }
}

链接接缝(类路径/模块解析):

  • 启用点在程序文本之外(构建脚本、导入映射)
  • 在链接时交换实现
  • 有用但更难注意

预处理接缝(仅限C/C++):

  • #include#define 操作
  • 最后手段 - 在现代语言中避免

特征化测试

目的:记录代码实际做什么,而不是它应该做什么。

过程

  1. 编写一个你知道会失败的测试
  2. 运行它 - 让失败告诉你实际行为
  3. 更改测试以期望实际行为
  4. 重复直到你特征化了代码
// 步骤1:编写失败测试
test("calculateFee 返回... 某物", () => {
  const result = calculateFee(100, "premium");
  expect(result).toBe(0); // 将失败,告诉我们实际值
});

// 步骤2:失败后显示“Expected 0, got 15”
test("calculateFee 对于 premium 和 100 返回 15", () => {
  const result = calculateFee(100, "premium");
  expect(result).toBe(15); // 现在记录实际行为
});

关键洞察:特征化测试验证行为是否存在,从而实现安全重构。它们不是关于正确性 - 而是关于保存。

打破依赖

当你无法实例化类时

参数化构造函数 - 外部化依赖:

// 之前
class MailChecker {
  constructor(checkPeriod: number) {
    this.receiver = new MailReceiver(); // 隐藏依赖
  }
}

// 之后 - 添加带默认值的参数
class MailChecker {
  constructor(
    checkPeriod: number,
    receiver: MailReceiver = new MailReceiver(),
  ) {
    this.receiver = receiver;
  }
}

提取接口 - 最安全的依赖打破:

// 1. 从类创建接口
interface MessageReceiver {
  receive(): Message[];
}

// 2. 让原始类实现它
class MailReceiver implements MessageReceiver { ... }

// 3. 创建测试替身
class FakeReceiver implements MessageReceiver {
  messages: Message[] = [];
  receive() { return this.messages; }
}

子类化和重写方法 - 核心技术:

// 生产类,有问题的方法
class OrderProcessor {
  protected getDatabase(): Database {
    return new ProductionDatabase(); // 在测试中无法使用
  }

  process(order: Order) {
    const db = this.getDatabase();
    // ... 处理逻辑
  }
}

// 测试子类
class TestableOrderProcessor extends OrderProcessor {
  protected getDatabase(): Database {
    return new InMemoryDatabase(); // 测试友好
  }
}

感知 vs 分离

感知:需要验证代码的效果(它做了什么?) 分离:需要独立运行代码(与依赖隔离)

根据你解决的问题选择技术。

安全添加新行为

发芽方法

在新方法中添加新行为,从现有代码调用它:

// 之前 - 需要添加验证
function processOrder(order: Order) {
  // ... 200行未测试代码
  saveOrder(order);
}

// 之后 - 发芽新的测试方法
function validateOrder(order: Order): ValidationResult {
  // 新代码,完全测试
}

function processOrder(order: Order) {
  const validation = validateOrder(order); // 一行新代码
  if (!validation.valid) return;
  // ... 200行未测试代码
  saveOrder(order);
}

发芽类

当新行为不适合现有类时:

// 新类用于新行为
class OrderValidator {
  validate(order: Order): ValidationResult { ... }
}

// 对现有代码的最小更改
function processOrder(order: Order) {
  const validator = new OrderValidator();
  if (!validator.validate(order).valid) return;
  // ... 现有未测试代码
}

包装方法

重命名现有方法,创建新方法包装它:

// 之前
function pay(employees: Employee[]) {
  for (const e of employees) {
    e.pay();
  }
}

// 之后 - 用日志包装
function pay(employees: Employee[]) {
  logPayment(employees); // 新行为
  dispatchPay(employees); // 重命名的原始方法
}

function dispatchPay(employees: Employee[]) {
  for (const e of employees) {
    e.pay();
  }
}

寻找测试点

效果草图

绘制方法影响什么:

method()
  → 修改 field1
  → 调用 helper() → 修改 field2
  → 基于 field3 返回值

捏点

捏点是一个狭窄的地方,可以检测到许多效果。

寻找这样的方法:

  • 被许多路径调用
  • 聚合来自多个操作的结果
  • 位于自然边界

捏点是理想的测试位置 - 一个测试覆盖许多代码路径。

拦截点

可以检测更改效果的地方:

  1. 返回值
  2. 修改的状态(字段、全局变量)
  3. 对其他对象的调用(模拟/间谍)

安全重构技术

保存签名

在没有测试的情况下打破依赖时:

  • 完全复制/粘贴方法签名
  • 不要更改参数类型或顺序
  • 依赖编译器捕获错误
// 安全:完全复制签名
function process(order: Order, options: Options): Result;
// 变成
function processInternal(order: Order, options: Options): Result;

草稿重构

重构以理解,然后丢弃:

  1. 进行激进更改以理解结构
  2. 不要提交 - 这是探索
  3. 恢复一切
  4. 现在你理解代码了
  5. 用测试进行真实更改

依赖编译器

使用类型系统作为安全网:

  1. 进行应该导致编译错误的更改
  2. 编译器显示所有受影响的位置
  3. 修复每个位置
  4. 如果编译通过,更改可能安全

决策树

需要向代码添加测试吗?
│
├─ 你现在能为其编写测试吗?
│  └─ 能 → 编写测试,进行更改,完成
│
└─ 不能 → 什么阻止了你?
   │
   ├─ 无法实例化类
   │  ├─ 隐藏依赖 → 参数化构造函数
   │  ├─ 太多依赖 → 提取接口
   │  └─ 构造函数做工作 → 提取和重写工厂方法
   │
   ├─ 无法在测试中调用方法
   │  ├─ 私有方法 → 通过公共接口测试(或设为受保护)
   │  ├─ 副作用 → 提取和重写调用
   │  └─ 全局状态 → 引入静态设置器(小心)
   │
   └─ 不理解代码
      ├─ 编写特征化测试
      ├─ 进行草稿重构(然后恢复)
      └─ 绘制效果草图

Kent Beck的简单设计四规则

来自Beck(由Corey Haines编纂) - 按优先级顺序:

  1. 测试通过 - 代码必须工作。没有这个,其他都不重要。
  2. 揭示意图 - 代码应清晰地表达它做什么。
  3. 无重复 - DRY,但特别是知识的重复,不仅仅是结构。
  4. 最少元素 - 移除任何不服务于以上三点的东西。

测试名称应影响API

测试名称揭示设计问题:

// 坏:测试名称与代码不匹配
test("a]live cell with 2 neighbors stays alive", () => {
  const cell = new Cell(true);
  cell.setNeighborCount(2);
  expect(cell.aliveInNextGeneration()).toBe(true);
});

// 更好:API匹配测试的语言
test("alive cell with 2 neighbors stays alive", () => {
  const cell = Cell.alive();
  expect(cell.aliveInNextGeneration({ neighbors: 2 })).toBe(true);
});

测试行为,而非状态

// 测试状态(脆弱)
test("sets alive to false", () => {
  const cell = new Cell();
  cell.die();
  expect(cell.alive).toBe(false);
});

// 测试行为(稳健)
test("dead cell stays dead with no neighbors", () => {
  const cell = Cell.dead();
  expect(cell.aliveInNextGeneration({ neighbors: 0 })).toBe(false);
});

知识重复 vs 结构重复

并非所有重复都是坏的。两段代码看起来相同但代表不同概念时,不应合并:

// 这些看起来相似但代表不同的领域概念
const MINIMUM_NEIGHBORS_TO_SURVIVE = 2;
const MINIMUM_NEIGHBORS_TO_REPRODUCE = 3;

// 不要仅仅因为数字接近而合并
// 它们因不同原因而改变

自测试代码(Fowler)

“自测试代码不仅启用重构 - 还使添加新功能更安全。”

关键洞察:当测试失败时,你知道确切什么坏了,因为你刚刚更改了它。测试是一个“强大的错误检测器,减少了找到错误的时间。”

测试隔离

  • 每个测试应独立
  • 不要让测试依赖于之前的测试
  • 测试应能按任何顺序运行

打破抽象级别

当测试在错误级别时,测试变得脆弱:

// 脆弱:测试实现细节
test("stores user in database", () => {
  createUser({ name: "Joel" });
  expect(db.query("SELECT * FROM users")).toContain({ name: "Joel" });
});

// 稳健:通过公共API测试行为
test("created user can be retrieved", () => {
  const id = createUser({ name: "Joel" });
  expect(getUser(id).name).toBe("Joel");
});

测试替身

伪造:带有捷径的工作实现(内存数据库) :返回预设答案给调用 模拟:验证交互发生 间谍:记录调用以供后续验证

优先伪造而非模拟 - 它们更真实且不易碎。

// 伪造 - 实际工作,只是更简单
class FakeEmailService implements EmailService {
  sent: Email[] = [];
  send(email: Email) {
    this.sent.push(email);
  }
}

// 模拟 - 验证交互
const mockEmail = mock<EmailService>();
// ... 代码运行 ...
expect(mockEmail.send).toHaveBeenCalledWith(expectedEmail);

参考

详细模式和示例:

  • references/dependency-breaking-catalog.md - 所有25种技术及示例