4重构(第2版)

建立测试体系

重构的安全网

自动化测试测试驱动开发

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第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 潜入生产环境。

javascript
// 没有测试的重构:每次修改都是一次赌博
// 你修改了函数内部结构,但不知道是否改变了行为
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 手动测试

手动测试的问题在于:

  • 速度慢:每次修改后都需要人工执行所有测试步骤
  • 容易遗漏:人总会忘记测试某些场景
  • 不可重复:不同的人可能以不同的方式执行测试
  • 成本高:随着系统增长,手动测试的成本线性增长

自动化测试的优势在于:

  • 速度快:几百个测试可以在几秒内完成
  • 可重复:每次执行的结果一致
  • 可回归:确保旧功能不被新功能破坏
  • 文档化:测试本身就是代码的使用文档
javascript
// 手动测试:每次都要人工操作
// 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:测试可能执行了代码,但没有验证正确的行为
  • 覆盖率不能替代好的测试设计:好的测试应该验证行为,而不是追求覆盖率数字
  • 覆盖率可能产生虚假的安全感:高覆盖率让你以为代码是安全的,但实际上测试可能很脆弱
javascript
// 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):先验证当前行为,而不是理想行为

逐步提高覆盖率:随着重构的推进,逐步添加更多测试

javascript
// 遗留代码:没有测试,难以理解
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 更加高效。两者相辅相成。

javascript
// 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 测试命名

测试的名称应该清晰地描述被测试的行为:

javascript
// 差的测试命名
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)

javascript
test('withdraw reduces balance', () => {
  // Arrange(准备)
  const account = new Account(100);
  
  // Act(执行)
  account.withdraw(30);
  
  // Assert(断言)
  expect(account.balance()).toBe(70);
});

2.6.3 测试独立性

每个测试应该独立于其他测试,不依赖执行顺序:

javascript
// 差的测试:依赖执行顺序
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 案例一:为遗留的用户认证模块添加测试

javascript
// 遗留代码:没有测试
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 的方式实现一个简单的功能,体验"红-绿-重构"的循环。


4/12建立测试
0%