TDD 與 Legacy Code
是什麼?
Michael Feathers 在《Working Effectively with Legacy Code》中定義:Legacy Code 就是沒有測試的程式碼。不管它多新或多舊,沒有測試就無法安全修改。
ℹ️Legacy Code 的定義
Legacy Code 不等於「老程式碼」。一個月前寫的、沒有測試的程式碼就是 Legacy Code。重點不是年齡,而是能否安全修改。
核心觀念
- Characterization Test(特徵測試):記錄現有行為的測試,不管行為對不對,先記下來。寫法:呼叫方法、觀察實際回傳值、把回傳值寫成 Assert。目的不是驗證正確性,而是「如果行為改變了,測試會告訴你」
- Seam(接縫):程式碼中可以插入測試替身而不需要修改原始程式碼的地方。三種常見 Seam:(1) Object Seam——透過建構子注入替換依賴;(2) Preprocessing Seam——用條件編譯或環境變數切換行為;(3) Link Seam——在連結階段替換實作(較少用)
- Sprout Method:在既有方法旁邊長出新方法,新方法有完整 TDD 測試。使用時機:你需要在現有方法中加入新行為,但現有方法太複雜無法直接測試
- Sprout Class:把新功能放到新類別中,新類別有完整測試。使用時機:新功能的依賴和現有類別差異太大,Sprout Method 無法乾淨地插入
- Wrap Method:用新方法包住舊方法,在前後加上新行為(有測試保護)。使用時機:你需要在現有方法的前後加入邏輯(如 logging、驗證),但不想碰舊方法的內部
- 依賴打破技術(Dependency-Breaking Techniques):Michael Feathers 定義了 25+ 種技術來解開糾纏的依賴。最常用的三種:(1) Extract Interface——為具體類別抽出介面;(2) Parameterize Constructor——把
new出來的依賴改成建構子參數;(3) Introduce Static Setter——為靜態依賴加入測試用的 setter(最後手段)
常見誤區
⚠️Legacy Code 改造的陷阱
- 想一次重寫全部:大規模重寫的失敗率極高。用小步驟、漸進式的方式改善
- 改了行為卻沒發現:沒有 Characterization Test 就重構,等於蒙著眼睛走鋼索
- 追求完美的測試覆蓋率:先測最危險的部分(常改動的、有 Bug 的),不需要一次全部補測試
- 在測試中引入更多依賴:打破依賴的目的是讓測試更簡單,不是更複雜
改造流程
辨識修改區域
找到你需要修改的程式碼區域,理解它的現有行為
找到 Seam(接縫)
找到可以插入測試的切入點,不需要改原始程式碼
寫 Characterization Test
記錄現有行為——不管對錯,先確保有測試覆蓋
打破依賴
用 Extract Interface、Introduce Parameter 等技術解開依賴
安全重構
在 Characterization Test 的保護下,小步驟重構程式碼
加入新功能
用 Sprout Method/Class 或 TDD 的方式加入新功能
程式碼範例
C#(xUnit)
// 原始 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)
// 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)
# 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 改造的完整路徑:先找到 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 | 用新方法包住舊方法,加上新行為 | | 核心原則 | 小步驟、漸進式改善,不要大規模重寫 |