第4章:构筑测试体系(Building Tests)
一、导读
1.1 本章学习目标
第4章是《重构》中最关键的实践基础。Martin Fowler 在本章中强调了一个核心观点:没有测试,你就不敢重构。测试是重构的安全网,它让你有信心在修改代码时不会引入新的 bug。
本章的核心学习目标包括:
- 理解测试在重构中的核心地位:为什么没有测试就不敢重构
- 掌握自动化测试的基本策略:如何编写可维护的测试
- 理解测试覆盖率的意义与局限:100% 覆盖率不等于没有 bug
- 学会为遗留代码添加测试:如何在没有测试的代码上开始重构
- 掌握测试驱动开发的基本流程:先写测试,再写代码
1.2 为什么本章如此重要
很多开发者认为测试是"可选的"——有时间就写,没时间就跳过。这种态度在重构的语境下是致命的。Fowler 明确指出:重构的前提是有可靠的测试。没有测试,每次修改都是一次赌博——你可能修复了一个问题,但同时引入了三个新问题。
测试的价值不仅在于发现 bug,更在于提供信心。当你有了完善的测试覆盖,你就可以大胆地重构代码,因为你知道:只要测试通过,代码的行为就没有改变。这种信心是持续改进代码的基础。
1.3 核心问题引导
在阅读本章时,请带着以下问题思考:
为什么测试是重构的前提?没有测试能重构吗?
自动化测试与手动测试有什么区别?为什么自动化测试更适合重构?
测试覆盖率是否越高越好?100% 覆盖率能保证没有 bug 吗?
如何为一个完全没有测试的遗留系统添加测试?
测试代码也需要维护吗?如何保持测试代码的整洁?
测试驱动开发(TDD)与重构有什么关系?
二、核心概念详解
2.1 测试是重构的安全网
重构的定义是"在不改变代码外在行为的前提下改善其内部结构"。关键词是"不改变外在行为"——但你怎么知道行为没有改变?答案是:运行测试。
测试就像攀岩时的安全绳。安全绳不能防止你失足,但它能在你失足时接住你,防止你摔到谷底。同样,测试不能防止你引入 bug,但它能在你引入 bug时立即告诉你,防止 bug 潜入生产环境。
// 没有测试的重构:每次修改都是一次赌博
// 你修改了函数内部结构,但不知道是否改变了行为
function calculateTotal(items) {
// 修改后的代码...
return total;
}
// 只能祈祷没有引入 bug
// 有测试的重构:每次修改都有验证
function calculateTotal(items) {
// 修改后的代码...
return total;
}
// 测试
test('calculateTotal with empty items returns 0', () => {
expect(calculateTotal([])).toBe(0);
});
test('calculateTotal with single item returns item price', () => {
expect(calculateTotal([{price: 10, quantity: 1}])).toBe(10);
});
test('calculateTotal with multiple items returns sum', () => {
expect(calculateTotal([
{price: 10, quantity: 2},
{price: 20, quantity: 1}
])).toBe(40);
});2.2 自动化测试 vs 手动测试
手动测试的问题在于:
- 速度慢:每次修改后都需要人工执行所有测试步骤
- 容易遗漏:人总会忘记测试某些场景
- 不可重复:不同的人可能以不同的方式执行测试
- 成本高:随着系统增长,手动测试的成本线性增长
自动化测试的优势在于:
- 速度快:几百个测试可以在几秒内完成
- 可重复:每次执行的结果一致
- 可回归:确保旧功能不被新功能破坏
- 文档化:测试本身就是代码的使用文档
// 手动测试:每次都要人工操作
// 1. 打开浏览器
// 2. 输入用户名和密码
// 3. 点击登录按钮
// 4. 检查是否跳转到首页
// 5. 检查用户名是否显示
// 问题:耗时、容易遗漏、不可重复
// 自动化测试:一键运行
test('user can login with valid credentials', async () => {
const result = await login('user@example.com', 'password123');
expect(result.redirectTo).toBe('/dashboard');
expect(result.displayName).toBe('User');
});
test('login fails with invalid password', async () => {
const result = await login('user@example.com', 'wrong');
expect(result.error).toBe('Invalid credentials');
});2.3 测试覆盖率的意义与局限
测试覆盖率衡量的是"测试执行了多少代码"。常见的覆盖率指标包括:
- 行覆盖率:有多少行代码被测试执行过
- 分支覆盖率:有多少条件分支被测试覆盖
- 路径覆盖率:有多少执行路径被测试遍历
覆盖率的价值在于:它告诉你哪些代码没有被测试覆盖。这些未覆盖的代码就是风险区域——你可能在修改它们时引入 bug 而不自知。
但覆盖率也有局限:
- 100% 行覆盖率不等于没有 bug:测试可能执行了代码,但没有验证正确的行为
- 覆盖率不能替代好的测试设计:好的测试应该验证行为,而不是追求覆盖率数字
- 覆盖率可能产生虚假的安全感:高覆盖率让你以为代码是安全的,但实际上测试可能很脆弱
// 100% 覆盖率但测试质量差的例子
function divide(a, b) {
if (b === 0) throw new Error("Division by zero");
return a / b;
}
// 这个测试覆盖了所有代码行,但没有测试边界情况
test('divide returns result', () => {
expect(divide(10, 2)).toBe(5);
});
// 更好的测试:覆盖边界情况和异常
test('divide returns correct result', () => {
expect(divide(10, 2)).toBe(5);
expect(divide(0, 5)).toBe(0);
expect(divide(-10, 2)).toBe(-5);
});
test('divide throws on division by zero', () => {
expect(() => divide(10, 0)).toThrow("Division by zero");
});2.4 为遗留代码添加测试
面对没有测试的遗留代码,Fowler 建议的策略是:
找到测试接缝(Test Seam):找到代码中可以"插入"测试的点
从小处开始:不要试图一次覆盖所有代码,从最关键的部分开始
使用特征测试(Characterization Test):先验证当前行为,而不是理想行为
逐步提高覆盖率:随着重构的推进,逐步添加更多测试
// 遗留代码:没有测试,难以理解
function process(data) {
let result = '';
for (let i = 0; i < data.length; i++) {
if (data[i] === ',') {
result += '|';
} else if (data[i] >= '0' && data[i] <= '9') {
result += data[i];
} else if (data[i] >= 'a' && data[i] <= 'z') {
result += data[i].toUpperCase();
} else {
result += '_';
}
}
return result;
}
// 第一步:写特征测试(验证当前行为,不管它是否"正确")
test('process replaces commas with pipes', () => {
expect(process('a,b,c')).toBe('A|B|C');
});
test('process keeps digits', () => {
expect(process('123')).toBe('123');
});
test('process uppercases lowercase letters', () => {
expect(process('abc')).toBe('ABC');
});
test('process replaces unknown chars with underscore', () => {
expect(process('a!b@c')).toBe('A_B_C');
});
// 第二步:有了测试后,安全地重构代码
function process(data) {
return data.split('').map(transformChar).join('');
}
function transformChar(char) {
if (char === ',') return '|';
if (isDigit(char)) return char;
if (isLowerCase(char)) return char.toUpperCase();
return '_';
}2.5 测试驱动开发(TDD)
TDD 的基本流程是:红-绿-重构(Red-Green-Refactor)。
红(Red):写一个失败的测试,描述你想要的新功能
绿(Green):写最少的代码让测试通过
重构(Refactor):改善代码结构,同时保持测试通过
TDD 与重构的关系是:TDD 提供了测试,使得重构成为可能;重构改善了代码,使得 TDD 更加高效。两者相辅相成。
// TDD 示例:实现一个购物车
// 第一步:红 - 写一个失败的测试
test('cart starts empty', () => {
const cart = new Cart();
expect(cart.itemCount()).toBe(0);
});
// 第二步:绿 - 写最少的代码让测试通过
class Cart {
itemCount() {
return 0;
}
}
// 第三步:红 - 写下一个失败的测试
test('can add item to cart', () => {
const cart = new Cart();
cart.add({ name: 'Apple', price: 1.5 });
expect(cart.itemCount()).toBe(1);
});
// 第四步:绿 - 更新代码
class Cart {
constructor() {
this.items = [];
}
add(item) {
this.items.push(item);
}
itemCount() {
return this.items.length;
}
}
// 第五步:重构 - 改善代码结构
// 添加更多功能:计算总价、移除商品等2.6 测试的最佳实践
2.6.1 测试命名
测试的名称应该清晰地描述被测试的行为:
// 差的测试命名
test('test1', () => { /* ... */ });
test('add', () => { /* ... */ });
// 好的测试命名
test('add returns sum of two positive numbers', () => { /* ... */ });
test('add throws error when adding negative numbers', () => { /* ... */ });
test('add handles floating point precision', () => { /* ... */ });2.6.2 测试结构(Arrange-Act-Assert)
test('withdraw reduces balance', () => {
// Arrange(准备)
const account = new Account(100);
// Act(执行)
account.withdraw(30);
// Assert(断言)
expect(account.balance()).toBe(70);
});2.6.3 测试独立性
每个测试应该独立于其他测试,不依赖执行顺序:
// 差的测试:依赖执行顺序
let account;
test('1. create account', () => {
account = new Account(100);
expect(account.balance()).toBe(100);
});
test('2. withdraw', () => {
account.withdraw(30); // 依赖上一个测试创建的 account
expect(account.balance()).toBe(70);
});
// 好的测试:每个测试独立
test('withdraw reduces balance', () => {
const account = new Account(100); // 每个测试自己创建
account.withdraw(30);
expect(account.balance()).toBe(70);
});三、实战案例
3.1 案例一:为遗留的用户认证模块添加测试
// 遗留代码:没有测试
function authenticate(username, password, db) {
const user = db.findUser(username);
if (!user) return { success: false, error: 'User not found' };
const hashedPassword = md5(password);
if (user.password !== hashedPassword) {
user.failedAttempts++;
if (user.failedAttempts >= 5) {
user.locked = true;
}
db.saveUser(user);
return { success: false, error: 'Invalid password' };
}
if (user.locked) {
return { success: false, error: 'Account locked' };
}
user.failedAttempts = 0;
user.lastLogin = new Date();
db.saveUser(user);
const token = jwt.sign({ userId: user.id }, 'secret');
return { success: true, token };
}
// 为遗留代码添加测试
describe('authenticate', () => {
let mockDb;
beforeEach(() => {
mockDb = {
findUser: jest.fn(),
saveUser: jest.fn()
};
});
test('returns error when user not found', () => {
mockDb.findUser.mockReturnValue(null);
const result = authenticate('unknown', 'pass', mockDb);
expect(result.success).toBe(false);
expect(result.error).toBe('User not found');
});
test('returns error when password is wrong', () => {
mockDb.findUser.mockReturnValue({
id: 1,
password: md5('correct'),
failedAttempts: 0
});
const result = authenticate('user', 'wrong', mockDb);
expect(result.success).toBe(false);
expect(result.error).toBe('Invalid password');
expect(mockDb.saveUser).toHaveBeenCalled();
});
test('increments failed attempts on wrong password', () => {
const user = { id: 1, password: md5('correct'), failedAttempts: 2 };
mockDb.findUser.mockReturnValue(user);
authenticate('user', 'wrong', mockDb);
expect(user.failedAttempts).toBe(3);
});
test('locks account after 5 failed attempts', () => {
const user = { id: 1, password: md5('correct'), failedAttempts: 4 };
mockDb.findUser.mockReturnValue(user);
authenticate('user', 'wrong', mockDb);
expect(user.locked).toBe(true);
});
test('returns token on successful login', () => {
mockDb.findUser.mockReturnValue({
id: 1,
password: md5('correct'),
failedAttempts: 0
});
const result = authenticate('user', 'correct', mockDb);
expect(result.success).toBe(true);
expect(result.token).toBeDefined();
});
});四、本章小结
第4章建立了重构的测试基础:
测试是重构的前提:没有测试就不敢重构
自动化测试是必须的:手动测试无法支撑频繁的重构
覆盖率是工具而非目标:关注测试质量,而非覆盖率数字
遗留代码也可以测试:从特征测试开始,逐步提高覆盖
TDD 与重构相辅相成:TDD 提供测试,重构改善代码
测试也需要维护:保持测试代码的整洁和可读性
本章的核心思想是:投资测试就是投资未来的可维护性。测试不是开发的速度杀手,而是速度的保障。
五、思考与练习
思考题
如果项目时间紧迫,是否可以跳过测试直接重构?风险是什么?
如何向非技术人员解释测试的价值?
测试覆盖率应该达到多少?如何确定合适的覆盖率目标?
如何处理测试中的"脆弱测试"(flaky tests)?
编程练习
为一个你熟悉的项目编写 5 个单元测试,确保覆盖核心业务逻辑。
尝试为一个没有测试的遗留函数编写特征测试,然后安全地重构它。
使用 TDD 的方式实现一个简单的功能,体验"红-绿-重构"的循环。