當人們談論測試時,通常指的是用來找 bug 的測試。但在 legacy code 的情境下,寫自動化測試來找 bug 其實不是最有效的策略。真正的目標是:在要改變的區域建立一張安全網。
在 legacy code 中,找 bug 不是首要目標——保留既有行為才是。自動化測試的價值不在於直接找 bug,而在於當你改變行為時,它們會以差異的形式揭露出 bug。
Characterization Tests(特徵化測試)#
我們需要的測試叫做 characterization tests——記錄系統實際行為的測試,而不是系統「應該」如何運作的測試。
定義#
Characterization test 是一個描述一段程式碼實際行為的測試。不是「嗯,它應該做這個」或「我覺得它會做那個」,而是「測試記錄了系統的實際當前行為」。
撰寫步驟#
- 在 test harness 中使用一段程式碼
- 寫一個你知道會失敗的 assertion
- 讓失敗訊息告訴你實際行為是什麼
- 修改測試,讓它期望程式碼實際產出的行為
- 重複
範例: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(特徵化類別)#
當我們有一個類別要了解該測試什麼,方法如下:
- 先嘗試了解類別在 high level 做什麼
- 為最簡單能想到的行為寫測試
- 讓好奇心引導你繼續探索
實用的啟發法#
- 尋找糾結的邏輯區塊:如果不理解一段程式碼,考慮引入 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 的啟發法)#
- 為你要改變的程式碼區塊撰寫測試,嘗試感知你的測試是否覆蓋了改變會影響的部分
- 如果不能確信,就加更多測試,直到你有信心
- 如果連這樣都沒信心,考慮用不同的方式來改變軟體,或許先做一部分
發現 Bug 時怎麼辦? 在 characterize 過程中你會持續發現 bug。如果系統從未部署過,直接修復。如果已經部署,需要考慮是否有人已經依賴了這個行為。傾向於在發現時就修復明顯的錯誤,對可疑的行為則標記為可疑再調查。