TDD 與 Legacy Code

是什麼?

Michael Feathers 在《Working Effectively with Legacy Code》中定義:Legacy Code 就是沒有測試的程式碼。不管它多新或多舊,沒有測試就無法安全修改。

ℹ️Legacy Code 的定義

Legacy Code 不等於「老程式碼」。一個月前寫的、沒有測試的程式碼就是 Legacy Code。重點不是年齡,而是能否安全修改

核心觀念

常見誤區

⚠️Legacy Code 改造的陷阱

  • 想一次重寫全部:大規模重寫的失敗率極高。用小步驟、漸進式的方式改善
  • 改了行為卻沒發現:沒有 Characterization Test 就重構,等於蒙著眼睛走鋼索
  • 追求完美的測試覆蓋率:先測最危險的部分(常改動的、有 Bug 的),不需要一次全部補測試
  • 在測試中引入更多依賴:打破依賴的目的是讓測試更簡單,不是更複雜

改造流程

1

辨識修改區域

找到你需要修改的程式碼區域,理解它的現有行為

2

找到 Seam(接縫)

找到可以插入測試的切入點,不需要改原始程式碼

3

寫 Characterization Test

記錄現有行為——不管對錯,先確保有測試覆蓋

4

打破依賴

用 Extract Interface、Introduce Parameter 等技術解開依賴

5

安全重構

在 Characterization Test 的保護下,小步驟重構程式碼

6

加入新功能

用 Sprout Method/Class 或 TDD 的方式加入新功能

程式碼範例

C#(xUnit)

csharp
// 原始 Legacy Code — 難以測試,依賴寫死
public class ReportGenerator
{
    public string Generate(int userId)
    {
        // 直接存取資料庫(無法測試)
        var db = new SqlConnection("Server=prod;...");
        var user = db.Query<User>($"SELECT * FROM Users WHERE Id={userId}");
 
        // 直接呼叫外部服務(無法測試)
        var emailService = new SmtpEmailService();
        emailService.Send(user.Email, "Report Ready");
 
        return $"Report for {user.Name}";
    }
}
 
// Step 1: 找到 Seam — 用 Extract Interface 打破依賴
public interface IUserRepository
{
    User GetById(int id);
}
 
public interface IEmailService
{
    void Send(string to, string subject);
}
 
// Step 2: 重構後的版本 — 依賴注入
public class ReportGenerator
{
    private readonly IUserRepository _repo;
    private readonly IEmailService _email;
 
    public ReportGenerator(IUserRepository repo, IEmailService email)
    {
        _repo = repo;
        _email = email;
    }
 
    public string Generate(int userId)
    {
        var user = _repo.GetById(userId);
        _email.Send(user.Email, "Report Ready");
        return $"Report for {user.Name}";
    }
}
 
// Step 3: Characterization Test — 記錄現有行為
[Fact]
public void Generate_ExistingUser_ReturnsReportWithName()
{
    var stubRepo = new Mock<IUserRepository>();
    stubRepo.Setup(r => r.GetById(1))
            .Returns(new User { Name = "Alice", Email = "a@test.com" });
    var mockEmail = new Mock<IEmailService>();
 
    var generator = new ReportGenerator(stubRepo.Object, mockEmail.Object);
    var result = generator.Generate(1);
 
    Assert.Equal("Report for Alice", result);
    mockEmail.Verify(e => e.Send("a@test.com", "Report Ready"));
}
 
// Step 4: Sprout Method — 新功能用 TDD 寫
public string GenerateWithSummary(int userId)
{
    var report = Generate(userId);
    var summary = BuildSummary(report); // 新方法,有完整 TDD
    return $"{report}\n{summary}";
}

TypeScript(Jest)

typescript
// Legacy Code — 直接呼叫 fetch,無法測試
class ReportGenerator {
  async generate(userId: number): Promise<string> {
    // 打破依賴:把 fetch 抽成可注入的依賴
    const user = await this.userRepo.getById(userId);
    await this.emailService.send(user.email, "Report Ready");
    return `Report for ${user.name}`;
  }
 
  constructor(
    private userRepo: UserRepository,
    private emailService: EmailService
  ) {}
}
 
// Characterization Test — 記錄現有行為
describe("ReportGenerator", () => {
  it("returns report with user name", async () => {
    const stubRepo = {
      getById: jest.fn().mockResolvedValue({
        name: "Alice",
        email: "a@test.com",
      }),
    };
    const mockEmail = { send: jest.fn() };
 
    const generator = new ReportGenerator(stubRepo, mockEmail);
    const result = await generator.generate(1);
 
    expect(result).toBe("Report for Alice");
    expect(mockEmail.send).toHaveBeenCalledWith("a@test.com", "Report Ready");
  });
});

Python(pytest)

python
# Legacy Code 改造流程
# Step 1: Characterization Test — 先記錄現有行為
def test_generate_existing_user_returns_report(mocker):
    mock_repo = mocker.Mock()
    mock_repo.get_by_id.return_value = User(name="Alice", email="a@test.com")
    mock_email = mocker.Mock()
 
    generator = ReportGenerator(mock_repo, mock_email)
    result = generator.generate(1)
 
    assert result == "Report for Alice"
    mock_email.send.assert_called_once_with("a@test.com", "Report Ready")
 
# Step 2: Sprout Method — 用 TDD 加新功能
def test_generate_with_summary_includes_summary():
    mock_repo = mocker.Mock()
    mock_repo.get_by_id.return_value = User(name="Alice", email="a@test.com")
    mock_email = mocker.Mock()
 
    generator = ReportGenerator(mock_repo, mock_email)
    result = generator.generate_with_summary(1)
 
    assert "Report for Alice" in result
    assert "Summary:" in result

概念圖

Legacy Code (無測試)
找到切入點
Characterization Test
有測試保護
Seam (接縫)
記錄現有行為
Sprout Method/Class
用 TDD 開發
Wrap Method
用 TDD 開發
安全重構
新功能長在旁邊
TDD 新功能

此圖展示 Legacy Code 改造的完整路徑:先找到 Seam(接縫),再用 Characterization Test 保護現有行為,有了安全網才能重構。新功能透過 Sprout 或 Wrap 技術加入,全程用 TDD 開發。關鍵:順序不能亂——沒有 Characterization Test 就動手重構是最危險的。

Sprout vs Wrap 的選擇

| 情境 | 選 Sprout | 選 Wrap | |------|-----------|---------| | 加入全新的業務邏輯 | 是 | 否 | | 在方法前後加 logging、驗證 | 否 | 是 | | 新功能和舊方法的依賴完全不同 | 是(用 Sprout Class) | 否 | | 需要改變舊方法的回傳值格式 | 否 | 是 |

實戰補充

💡資深開發者的經驗

  • 先讀《Working Effectively with Legacy Code》:Michael Feathers 這本書是 Legacy Code 改造的聖經,定義了 25 種以上的依賴打破技術。
  • Boy Scout Rule:每次碰到 Legacy Code,就讓它比你發現時好一點點。不需要一次修好全部。
  • Golden Master Testing:對於複雜的 Legacy Code,先記錄完整的輸入/輸出,任何改動都跟 Golden Master 比對。
  • 最危險的區域優先:先補測試在最常修改、最容易出 Bug 的區域。用 git log --follow 找到修改頻率最高的檔案。
  • 不要害怕「醜陋」的測試:Characterization Test 不追求漂亮,它的目的是保護你的安全。先有再好。

理解測驗

🤔 Michael Feathers 對 Legacy Code 的定義是什麼?

🤔 Characterization Test 的目的是什麼?

🤔 Sprout Method 的做法是什麼?

重點整理

💡一句話記住

口訣:「沒有測試就是 Legacy,先搭鷹架再動刀,小步前進不蠻幹」。

| 技術 | 說明 | |------|------| | Characterization Test | 記錄現有行為的測試,保護重構安全 | | Seam(接縫) | 可以插入測試的程式碼切入點 | | Sprout Method | 新功能長在舊方法旁邊,用 TDD 開發 | | Sprout Class | 新功能放到新類別,用 TDD 開發 | | Wrap Method | 用新方法包住舊方法,加上新行為 | | 核心原則 | 小步驟、漸進式改善,不要大規模重寫 |

你可能也想看

參數化測試BDD(行為驅動開發)

按 ← → 鍵切換課程