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:编写失败测试
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 返回值
捏点
捏点是一个狭窄的地方,可以检测到许多效果。
寻找这样的方法:
- 被许多路径调用
- 聚合来自多个操作的结果
- 位于自然边界
捏点是理想的测试位置 - 一个测试覆盖许多代码路径。
拦截点
可以检测更改效果的地方:
- 返回值
- 修改的状态(字段、全局变量)
- 对其他对象的调用(模拟/间谍)
安全重构技术
保存签名
在没有测试的情况下打破依赖时:
- 完全复制/粘贴方法签名
- 不要更改参数类型或顺序
- 依赖编译器捕获错误
// 安全:完全复制签名
function process(order: Order, options: Options): Result;
// 变成
function processInternal(order: Order, options: Options): Result;
草稿重构
重构以理解,然后丢弃:
- 进行激进更改以理解结构
- 不要提交 - 这是探索
- 恢复一切
- 现在你理解代码了
- 用测试进行真实更改
依赖编译器
使用类型系统作为安全网:
- 进行应该导致编译错误的更改
- 编译器显示所有受影响的位置
- 修复每个位置
- 如果编译通过,更改可能安全
决策树
需要向代码添加测试吗?
│
├─ 你现在能为其编写测试吗?
│ └─ 能 → 编写测试,进行更改,完成
│
└─ 不能 → 什么阻止了你?
│
├─ 无法实例化类
│ ├─ 隐藏依赖 → 参数化构造函数
│ ├─ 太多依赖 → 提取接口
│ └─ 构造函数做工作 → 提取和重写工厂方法
│
├─ 无法在测试中调用方法
│ ├─ 私有方法 → 通过公共接口测试(或设为受保护)
│ ├─ 副作用 → 提取和重写调用
│ └─ 全局状态 → 引入静态设置器(小心)
│
└─ 不理解代码
├─ 编写特征化测试
├─ 进行草稿重构(然后恢复)
└─ 绘制效果草图
Kent Beck的简单设计四规则
来自Beck(由Corey Haines编纂) - 按优先级顺序:
- 测试通过 - 代码必须工作。没有这个,其他都不重要。
- 揭示意图 - 代码应清晰地表达它做什么。
- 无重复 - DRY,但特别是知识的重复,不仅仅是结构。
- 最少元素 - 移除任何不服务于以上三点的东西。
测试名称应影响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种技术及示例