當人們談論測試時,通常指的是用來找 bug 的測試。但在 legacy code 的情境下,寫自動化測試來找 bug 其實不是最有效的策略。真正的目標是:在要改變的區域建立一張安全網

在 legacy code 中,找 bug 不是首要目標——保留既有行為才是。自動化測試的價值不在於直接找 bug,而在於當你改變行為時,它們會以差異的形式揭露出 bug。

Characterization Tests(特徵化測試)#

我們需要的測試叫做 characterization tests——記錄系統實際行為的測試,而不是系統「應該」如何運作的測試。

定義#

Characterization test 是一個描述一段程式碼實際行為的測試。不是「嗯,它應該做這個」或「我覺得它會做那個」,而是「測試記錄了系統的實際當前行為」。

撰寫步驟#

  1. 在 test harness 中使用一段程式碼
  2. 寫一個你知道會失敗的 assertion
  3. 讓失敗訊息告訴你實際行為是什麼
  4. 修改測試,讓它期望程式碼實際產出的行為
  5. 重複

範例:PageGenerator#

// 步驟 1:寫一個一定會失敗的測試
void testGenerator() {
    PageGenerator generator = new PageGenerator();
    assertEquals("fred", generator.generate());
}

執行後失敗,告訴我們實際回傳的是空字串:

junit.framework.ComparisonFailure: expected:<fred> but was:<>
// 步驟 2:修正為實際行為
void testGenerator() {
    PageGenerator generator = new PageGenerator();
    assertEquals("", generator.generate());
}

測試通過——現在記錄了一個基本事實:新建的 PageGenerator 會產生空字串。

繼續餵入不同資料:

void testGenerator() {
    PageGenerator generator = new PageGenerator();
    generator.assoc(RowMappings.getRow(Page.BASE_ROW));
    assertEquals("fred", generator.generate());
}

失敗訊息告訴我們實際結果是 "<node><carry>1.1 vectrai</carry></node>",於是更新測試:

void testGenerator() {
    PageGenerator generator = new PageGenerator();
    assertEquals("<node><carry>1.1 vectrai</carry></node>",
            generator.generate());
}

這真的在測試什麼嗎?#

直覺上會質疑:如果只是把程式碼的輸出放進測試,這還算測試嗎?但換個角度想:

  • 這些測試不是一個軟體必須符合的黃金標準
  • 我們不是在找 bug,而是在建立一個機制,讓未來的 bug 以「與系統當前行為的差異」形式顯現
  • 它們只是靜靜地記錄系統的真實行為

Characterization tests 記錄的是程式碼的實際行為。如果在撰寫時發現意外的行為,值得去釐清——它可能是 bug。但不要直接放進測試套件,而是標記為可疑並調查。

Characterizing Classes(特徵化類別)#

當我們有一個類別要了解該測試什麼,方法如下:

  1. 先嘗試了解類別在 high level 做什麼
  2. 為最簡單能想到的行為寫測試
  3. 讓好奇心引導你繼續探索

實用的啟發法#

  • 尋找糾結的邏輯區塊:如果不理解一段程式碼,考慮引入 sensing variable 來 characterize 它,確保你執行到特定的程式碼區域
  • 發現職責後列出可能出錯的事:看看能否寫測試來觸發它們
  • 思考極端值:你提供的輸入在極端情況下會怎樣?
  • 尋找 invariants:在類別的生命週期中,是否有任何條件始終為真?嘗試寫測試來驗證它們

The Method Use Rule(方法使用規則): 在 legacy system 中使用一個方法之前,先檢查是否有測試。如果沒有,就寫。當你持續這樣做,測試就成為一種溝通媒介——人們可以看到方法能做什麼、不能做什麼。

測試的組織#

測試就像文件一樣,要考慮讀者的需求:

  • 先放展示類別主要意圖的簡單案例
  • 再放突顯特殊行為的案例
  • 把在 characterization 過程中發現的重要事實記錄下來

Targeted Testing(針對性測試)#

寫完理解性測試後,我們需要檢視要做的改變,確認測試是否真的覆蓋了它們。

範例:FuelShare#

public class FuelShare {
    private long cost = 0;
    private double corpBase = 12.0;
    private ZonedHawthorneLease lease;
    ...
    public void addReading(int gallons, Date readingDate){
        if (lease.isMonthly()) {
            if (gallons < Lease.CORP_MIN)
                cost += corpBase;
            else
                cost += 1.2 * priceForGallons(gallons);
        }
        ...
        lease.postReading(readingDate, gallons);
    }
}

我們想把 top-level if-statement 提取到新方法,並移到 ZonedHawthorneLease 類別:

// 重構後
public class FuelShare {
    public void addReading(int gallons, Date readingDate){
        cost += lease.computeValue(gallons,
                                    priceForGallons(gallons));
        ...
        lease.postReading(readingDate, gallons);
    }
}

public class ZonedHawthorneLease extends Lease {
    public long computeValue(int gallons, long totalPrice) {
        long cost = 0;
        if (lease.isMonthly()) {
            if (gallons < Lease.CORP_MIN)
                cost += corpBase;
            else
                cost += 1.2 * totalPrice;
        }
        return cost;
    }
}

要確保正確重構,需要考慮:

  • gallons < Lease.CORP_MIN 的分支不會被修改,有測試會不錯但非必要
  • else 分支會改變(priceForGallons(gallons) 變成 totalPrice 參數),一定需要測試
  • isMonthly() 回傳 false 的情況也需要確認

A Heuristic for Writing Characterization Tests(撰寫 Characterization Tests 的啟發法)#

  1. 為你要改變的程式碼區塊撰寫測試,嘗試感知你的測試是否覆蓋了改變會影響的部分
  2. 如果不能確信,就加更多測試,直到你有信心
  3. 如果連這樣都沒信心,考慮用不同的方式來改變軟體,或許先做一部分

發現 Bug 時怎麼辦? 在 characterize 過程中你會持續發現 bug。如果系統從未部署過,直接修復。如果已經部署,需要考慮是否有人已經依賴了這個行為。傾向於在發現時就修復明顯的錯誤,對可疑的行為則標記為可疑再調查。