「然而,沒有測試套件,他們就喪失確保『程式修改後是否仍能照預期般工作』的能力,他們沒辦法保證『對系統某部分的修改不會搞爛系統其他部分的程式』。所以他們的程式缺陷率開始上升」
「他們開始害怕修改程式,他們的產品程式開始腐壞,最後變成沒有任何測試、混亂和 Bug 叢生的產品程式」
取自: Clean Code (p.140)
關於整潔的測試這個議題,本身就能寫成一本書,本章節僅做簡單概念介紹
測試程式保存和加強了產品的:
原因很簡單,有了測試程式,你就不會害怕修改程式。無論你的程式架構分割的多好,每一次的修改都可能潛藏錯誤。沒有了測試,你將害怕改變會導致其他尚未察覺的錯誤!
先寫測試程式反而能加快產品開發的速度
作者的朋友 Jason Gorman 以一個將羅馬數字與整數互相轉換的小程式為例,共切分 6 個小階段開發程式,並故意間隔採用 TDD 開發策略 (即,開發功能前先寫好測試程式):
取自: Clean Architecture (p.9)
我們可以發現:
我們或許可以得到兩個啟發:
「測試程式對一個軟體專案的影響程度就跟產品程式一樣重要」
「想要走的快,唯一的方式就是要走的好」
取自: Clean Code (p.150) & Clean Architecture (p.10)
測試會覆蓋所有的產品程式,數量足以和產品程式匹敵,將產生管理問題
測試程式會隨著產品程式的演進而修改,而當測試程式越陷入一團混亂時,所花的時間可能比開發新產品還要多。當開發者將錯誤歸咎於測試套件時,他們會將整個測試套件都捨棄掉
「然而,沒有測試套件,他們就喪失確保『程式修改後是否仍能照預期般工作』的能力,他們沒辦法保證『對系統某部分的修改不會搞爛系統其他部分的程式』。所以他們的程式缺陷率開始上升」
「他們開始害怕修改程式,他們的產品程式開始腐壞,最後變成沒有任何測試、混亂和 Bug 叢生的產品程式」
測試程式跟產品程式一樣重要
「容許測試程式可以是混亂的」,是失敗的源頭。測試程式也需要花時間思考、設計、和維護
良好的測試例子: Build-Operate-Check
// Test 1
public void testGetPageHierarchyAsXml() throws Exception {
makePages("PageOne", "PageOne.ChildOne", "PageTwo");
submitRequest("root", "type:pages");
assertResponseIsXML();
assertResponseContains(
"<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>"
);
}
// Test 2
public void testSymbolicLinksAreNotInXmlPageHierarchy() throws Exception {
WikiPage page = makePage("PageOne");
makePages("PageOne.ChildOne", "PageTwo");
addLinkTo(page, "PageTwo", "SymPage");
submitRequest("root", "type:pages");
assertResponseIsXML();
assertResponseContains(
"<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>"
);
assertResponseDoesNotContain("SymPage");
}
// Test 3
public void testGetDataAsXml() throws Exception {
makePageWithContent("TestPageOne", "test page");
submitRequest("TestPageOne", "type:data");
assertResponseIsXML();
assertResponseContains("test page", "<Test>");
}
上述的每個測試都被拆解成三個部分
1. 建立測資
2. 操作測資
3. 結果是否如預期
任何人都可以在不被細節誤導和干擾的情況下,馬上瞭解測試程式
上述例子雖然採用了 Build-Operate-Cehck 的模式來設計測試程式,但仍有另一派人認為,每個測試函式都只能有唯一的一個 Assert。好處是每個測試都只會產生一個結論,人們可以更快速容易地瞭解它們。讓我們以此概念來改寫上述例子:
// Test 1 (Refactored)
public void testGetPageHierarchyAsXml() throws Exception {
givenPages("PageOne", "PageOne.ChildOne", "PageTwo");
whenRequestIsIssued("root", "type:pages");
thenResponseShouldBeXML();
}
public void testGetPageHierarchyAsXml() throws Exception {
givenPages("PageOne", "PageOne.ChildOne", "PageTwo");
whenRequestIsIssued("root", "type:pages");
thenResponseShouldContain(
"<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>"
);
}
注意: 這邊依據[1] Given-When-Then 的概念來替換了函式的名稱,這讓程式可讀性上升。不幸的是,這樣的拆解會導致重複程式碼產生
要解決 Give-When-Then 模式產生的重複程式碼,可以利用 Template Method [2]設計模式來提取共用程式碼。也就是將 Given/When 提取至基底類別,Then 則放在不同衍生類別
斷言僅能唯一或許是一種較為極端的作法,但不論如何,謹記:
「測試裡的斷言應該盡可能地減少」
public void testAddMonths() {
SerialDate d1 = SerialDate.createInstance(31, 5, 2004);
SerialDate d2 = SerialDate.addMonths(1, d1);
assertEquals(30, d2.getDayOfMonth());
assertEquals(6, d2.getMonth());
assertEquals(2004, d2.getYYYY());
SerialDate d3 = SerialDate.addMonths(2, d1);
assertEquals(31, d3.getDayOfMonth());
assertEquals(7, d3.getMonth());
assertEquals(2004, d3.getYYYY());
SerialDate d4 = SerialDate.addMonths(1, SerialDate.addMonths(1, d1));
assertEquals(30, d4.getDayOfMonth());
assertEquals(7, d4.getMonth());
assertEquals(2004, d4.getYYYY());
}
「測試程式對於一個專案的健康程度,就跟產品程式一樣重要。如果你讓測試程式腐敗,那麼你的產品程式也會跟著腐敗。保持你的測試整潔」
P.S. 關於 TDD 的探討在國外也有另一群人批評其為 "Cargo Cult" (邪教),有興趣的讀者們可以自行 Google 查找相關資訊。另外,究竟台灣的職場環境適不適合導入測試驅動開發(或說,該如何說服主管?),筆者挺好奇各位大大們的看法,歡迎交流~