本章探討如何在既有的 legacy code 中導入單元測試。面對龐大且缺乏測試的舊系統,開發者往往不知從何著手。作者分享了他在一家擁有超過一萬名開發者的大型公司擔任顧問的經驗,指出 legacy code 面臨的核心挑戰:難以對既有程式碼撰寫測試、幾乎不可能重構、缺乏適當工具、以及不知從何開始。

10.1 從哪裡開始加入測試#

面對既有系統,首先要建立一份元件優先順序清單,決定哪些元件最值得優先測試。評估每個元件時需考量三個因素:

  • 邏輯複雜度(Logical Complexity):元件中包含多少邏輯,例如巢狀 ifswitch、遞迴等。可用 cyclomatic complexity 工具來衡量
  • 依賴數量(Dependency Level):需要打破多少依賴才能將該元件納入測試。它是否與外部 email 元件溝通?是否呼叫靜態 log 方法?
  • 專案優先級(Priority):該元件在專案中的重要程度,從 1(低)到 10(高)

作者將這三個因素整理成一張測試可行性表格(Test-Feasibility Table)

元件邏輯複雜度依賴數量優先級說明
Utils615少依賴、多邏輯,容易測試且價值高
Person211純資料類別,邏輯少,測試價值有限
TextParser846邏輯多、依賴多、優先級高,測試價值高但耗時
ConfigManager161邏輯少但依賴多,測試價值低且耗時

根據表格資料,可以將元件繪製在以**邏輯(Logic)**為 Y 軸、**依賴(Dependencies)**為 X 軸的圖表上:

Figure 10.1: Mapping components for test feasibility

可以安全地忽略邏輯複雜度低於門檻值(作者建議設為 2 或 3)的元件,只留下值得測試的元件。接著面臨兩個選擇方向:

  • 選擇邏輯複雜但容易測試的元件(左上方,依賴少)
  • 選擇邏輯複雜但困難測試的元件(右上方,依賴多)

Figure 10.2: Easy, hard, and irrelevant components to test

10.2 選擇測試策略#

確定了哪些元件值得測試後,接下來要決定從容易的還是困難的開始。

容易優先策略(Easy-First Strategy)的利弊#

從依賴較少的元件開始,初期撰寫測試會快很多。但隨著時間推進,剩下的元件會越來越難測試,最困難的元件往往留到專案末期——正好是大家壓力最大、趕著上線的時候。

Figure 10.3: Time to bring components under test when starting with the easiest

如果團隊對單元測試經驗尚淺,建議從容易的元件開始。團隊可以在過程中學習技巧,逐步建立信心。對這樣的團隊,可以先設定一個依賴數量上限(例如 4),暫時跳過超過上限的元件。

困難優先策略(Hard-First Strategy)的利弊#

從依賴最多的元件開始,初期投入的時間會非常高,可能花一天以上才能讓最簡單的測試跑起來。但好處是:每次為高依賴元件重構並解決可測試性問題時,同時也在為其他元件鋪路——因為高依賴元件的依賴往往也是其他元件的依賴。因此,撰寫測試所需的時間會快速下降

Figure 10.4: Average time to write a test using a hard-first strategy

困難優先策略僅適用於團隊已有單元測試經驗的情況。如果團隊有經驗,可以搭配元件的專案優先級來決定先測試哪些困難的元件。也可以混合使用兩種策略,但務必事先了解每種策略所需的投入與可能的後果。

10.3 在重構前先寫整合測試#

如果你打算重構程式碼以提升可測試性,一個務實的做法是:先針對正式系統撰寫整合測試,確保重構過程不會破壞既有功能。

作者分享了一個實際案例:在一個 C++ legacy 專案中,需要為 XML 設定管理元件新增一個屬性。他們的做法是:

  1. 撰寫整合測試驗證原有行為:用真實系統(非 mock)來存取設定資料,確認元件能正確讀寫,作為基準線
  2. 新增一個失敗的測試:驗證新屬性的功能(此時尚未實作,所以測試會失敗)
  3. 實作新功能,讓新測試通過
  4. 執行所有整合測試(原有的兩個加上新的一個),確認三個都通過,代表既有功能未被破壞

整個流程可以歸納為:

  • 新增整合測試(不使用 mock 或 stub)來驗證原系統的行為
  • 為要新增的功能加入失敗的測試
  • 小幅度重構與修改,頻繁執行整合測試,確認沒有破壞任何東西
flowchart TD
    A[撰寫整合測試驗證原有行為] --> B[新增失敗的測試]
    B --> C[實作新功能]
    C --> D[執行所有整合測試]
    D --> E{全部通過?}
    E -->|否| C
    E -->|是| F[完成]

整合測試有時比單元測試更容易撰寫,因為不需要處理依賴注入。但在本機執行整合測試可能較麻煩,因為必須確保系統的每個環節都就位。重點是專注在需要修改或新增功能的部分,其他部分留待日後處理。

隨著整合測試越來越多,就可以開始重構系統、加入單元測試,逐步將系統轉變為可維護且可測試的狀態。這需要時間(可能數月),但值得投入。

10.4 Legacy Code 單元測試的重要工具#

以下是作者推薦的工具與資源,可以在處理 legacy code 時提供幫助。

10.4.1 不受限隔離框架(Unconstrained Isolation Frameworks)#

不受限隔離框架(如 Typemock Isolator、JustMock)是處理 legacy code 最強大的武器。它們能夠在不修改正式程式碼設計的情況下,fake 掉各種依賴——包括 interface、sealed/static 類型、非虛擬方法、靜態方法等。

Typemock Isolator 為例,它使用 fake 這個術語(而非 mock 和 stub),讓你可以立即開始測試,不需要花時間重構設計:

[Test]
public void FakeAStaticMethod()
{
    Isolate
        .WhenCalled(() => MyClass.SomeStaticMethod())
        .WillThrowException(new Exception());
}

[Test]
public void FakeAPrivateMethodOnAClassWithAPrivateConstructor()
{
    ClassWithPrivateConstructor c =
        Isolate.Fake.Instance<ClassWithPrivateConstructor>();
    Isolate.NonPublic
        .WhenCalled(c, "SomePrivateMethod").WillReturn(3);
}

作者不推薦使用 Microsoft Fakes,雖然它是免費的,但其設計(code generation、大量 delegate)會產生大量難以維護的測試程式碼。微軟自己的文件也承認:如果重構了被測試的程式碼,之前用 Shims 和 Stubs 寫的測試將無法編譯,且沒有簡單的解決方案。

10.4.2 JMockit(Java Legacy Code 專用)#

JMockit(或 PowerMock)是使用 Java instrumentation API 的開源專案,功能類似 .NET 的 Typemock Isolator。它採用 swap 方法:先手動建立一個 fake 類別來替換依賴,再用 JMockit 在執行時期將原始類別替換為 fake 類別。也可以在測試中用匿名方法重新定義類別的方法。

public class ServiceATest extends TestCase {
    private boolean serviceMethodCalled;

    public static class MockDatabase {
        static int findMethodCallCount;
        static int saveMethodCallCount;
        // ... mock implementations
    }

    protected void setUp() throws Exception {
        super.setUp();
        Mockit.redefineMethods(Database.class,
            MockDatabase.class);
    }

    public void testDoBusinessOperationXyz() throws Exception {
        final BigDecimal total = new BigDecimal("125.40");
        Mockit.redefineMethods(ServiceB.class, new Object() {
            public BigDecimal computeTotal(List items) {
                assertNotNull(items);
                serviceMethodCalled = true;
                return total;
            }
        });
        // ... assertions
    }
}

10.4.3 Vise(Java 重構輔助工具)#

Vise 是 Michael Feathers 開發的 Java 工具,用於在重構過程中驗證值沒有被意外改變。透過在程式碼中插入 Vise.grip() 呼叫,每次執行時會檢查傳入變數的值是否仍然符合預期,就像在程式碼中加入內部 assert。Vise 也能報告所有被「grip」的項目及其當前值。

import vise.tool.*;

public class RPRequest {
    public int process(int level, RPPacket packet) {
        // ...
        bar_args[1] += list.size();
        Vise.grip(bar_args[1]);          // 抓住值
        packet.add(new Subpacket(list, arrivalTime));
        if (packet.calcSize() > 2)
            bar_args[1] += 2;
        Vise.grip(bar_args[1]);          // 再次檢查
        // ...
    }
}

10.4.4 重構前先用驗收測試(FitNesse)#

在重構之前,先用 FitNesse(或 Cucumber、SpecFlow)建立整合與驗收測試套件是好做法。FitNesse 允許你用 HTML 表格定義測試案例,不需撰寫程式碼就能新增測試。

使用 FitNesse 的三個步驟:

  1. 建立 fixture 類別來包裝正式程式碼,代表使用者可能執行的動作
  2. 在 FitNesse wiki 網站上用特殊語法建立 HTML 表格,定義測試資料
  3. 點擊「Execute Tests」按鈕,FitNesse 引擎會呼叫 fixture 並驗證結果

Figure 10.5: Using FitNesse for integration testing

作者個人認為 FitNesse 的易用性不佳,推薦可以考慮 Cucumber 作為替代方案。

10.4.5 Michael Feathers 的《Working Effectively with Legacy Code》#

作者強烈推薦 Michael Feathers 的 《Working Effectively with Legacy Code》,認為這是唯一一本深入探討 legacy code 重構技巧與陷阱的書籍。書中涵蓋許多本書未觸及的重構技術,「值回票價」。

10.4.6 NDepend#

NDepend 是一款 .NET 商業分析工具,能以視覺化方式呈現已編譯組件的各種面向,包括:

  • 依賴樹(dependency trees)
  • 程式碼複雜度
  • 同一組件不同版本的變化

NDepend 最強大的功能是其特殊查詢語言 CQL(Code Query Language),可以對程式碼結構進行查詢,例如找出所有具有 private 建構子的元件。

10.4.7 ReSharper#

ReSharper 是 VS.NET 的生產力外掛,提供強大的自動化重構能力與程式碼導覽功能:

  • 從類別或方法宣告跳轉到其繼承者或基底實作
  • 找出所有使用特定變數的位置
  • 找出所有實作某個介面的類別

這些導覽功能在面對陌生的 legacy code 時特別有用。

10.4.8 Simian 與 TeamCity 偵測重複程式碼#

  • TeamCity 內建 .NET 的重複程式碼偵測器
  • Simian 是一款商業產品,支援 .NET、Java、C++ 等多種語言的程式碼重複偵測,可以幫助追蹤重複程式碼的數量並進行重構

偵測重複程式碼在處理 legacy code 時特別重要——如果你修正了一個 bug,你會想確認同樣的 bug 沒有被複製到其他地方。

10.5 總結#

本章的核心要點:

  • 建立元件地圖:根據依賴數量、邏輯複雜度和專案優先級來繪製元件地圖,決定測試順序
  • 選擇策略:團隊經驗不足時從容易的元件開始;有經驗的團隊可以從困難的元件開始,因為解決高依賴元件的可測試性問題會加速後續工作
  • 善用不受限隔離框架:如果不想重構但想開始測試,使用 Typemock Isolator(.NET)或 JMockit / PowerMock(Java)可以在不改變設計的情況下隔離依賴
  • 整合測試先行:重構前先寫整合測試作為安全網
  • 善用工具:NDepend、ReSharper、Simian 等工具可以在不同階段提供幫助

作者最後幽默地引用朋友的話:「處理 legacy code 時,一瓶好的伏特加絕對不會有壞處。」