Test infected, 這個是 Test 大師 Kent Beck 形容 "非寫 test,不然就沒辦法 coding" 的一種習慣。這個字該怎麼翻好呢? "被測試感染"、"感染到測試"、"後天性測試症候群"、"急性測試炎".... -_-;; 好像太離譜了~~ 還是等待大師來翻譯正名吧.
Anyway, 這個禮拜又玩了一下 test-first 理論。這次從兩個方向著手,第一個是 service oriented 的程式。service 嘛,因為只知道它要做什麼,怎麼個做法還不知道,所以自然是從上到下 (top-down) 的方式來開發,先從最外面的輪廓著手:
//[code snip#1]
public class DataService {
public void sendData(String data) {;}
}
(note: test 比這個早寫完)
接下來開始工作讓這個 service 工作,這個 service 會送資料到某個檔案,所以就變成這樣:
//[code snip#2 心中閃過的念頭,假的]
public class FileDataService {
public FileDataService(String fileName) {
this.fileName = fileName ;
//etc....
}
public void sendData(String data) {
String newFormat = formatConversion(data);
new FileWriter(fileName).write(.....)
//etc
}
}
其實這個 FileDataService 並沒有真的寫出來,因為先寫 test 就知道 ==> 有夠難測!為了要 assert 這個程式的結果,我還要去找個真的檔案來寫,寫完後再來個 FileReader,讀完了之後,再把這個檔案給 purge 掉,暈倒... 為了讓 test 好寫,就將 fileName 拿掉,改用 OutputStream 當成介面:
//[code snip#3 test寫完後的第二個版本]
public class DataService {
public DataService(OutputStream out) {
this.out = out ;
//etc....
}
public void sendData(String data) {
String newFormat = formatConversion(data);
out.write(...);
//etc
}
}
呼呼,這下 output 的來源被丟出去了,改成從 constructor 進來,這就叫 constructor injection 吧?測試這個程式就簡單多啦,我可以 new ByteArrayOutputStream() 給它,然後就.... 嘿,這個留給看倌自己測試啦,這個 class 很好用地~~ (hint: toByteArray() )。
後來這個程式演變成:
//[code snip#4 第三個版本]
public class DataService {
public DataService(OutputFactory factory) {
this.factory = factory ;
//etc....
}
public void sendData(String data) {
String newFormat = formatConversion(data);
OutputStream out = factory.createOutputStream();
out.write(...);
//etc
}
}
用一個factory來建立新的 OutputStream,因為這樣這個 service 才能送資料到不同地方,而這個 DataService 在進行測試時,其實 OutputFactory 還沒寫好,只是使用一個 dummy 的 MockOutputFactory 來輔助測試,DataService 才不管要寫到哪裡去咧,這是 OutputFactory 的事。它專心做它該做的事 (data conversion, write data... etc) 。由上面這個例子可知 test-first 帶來了:
逼你去想好測試的方法,把不相干的東西盡量丟到外部去,唯有這樣 Object Isolation 才會好。
Ok, 現在進入第二個例子:bottom-up,就是由小到大,從細部做起:我想設計一個 TableModel,有 cell, 也有 row,然後可以定義 cell 的 format,而且還有一個特殊功能,可以合併 table。由小到大,先寫 cell 、再寫 row、最後寫 Table
//[code snip#5 由小到大]
public class DataCell {
public DataCell(Double data, DecimalFormat format) {;}
}
public class DataRow {
public DataRow(int initialSize) {;}
public void addCell(DataCell cell);
public void appendRow(DataRow rowToCombine);
}
上面是寫完的結果,步驟是這樣地:首先先寫 DataCell 的 test,再完成 DataCell 本身 (DataCell設計成 immutable) 。有了 DataCell 之後,就開始寫 DataRow 的 test,這個 test 就好寫了,因為已經有"測試完整"的 DataCell 可用,不用費心去做 Mock。而那個特別的 appendRow() method,只做了個大概,這是想到等一下要用來合併 Table的 "原料",也許用不著也說不定。有了 DataRow 就可以拿來寫 DataTable 了:
//[code snip#6 完成 DataTable]
public class DataTable {
public DataTable(int initialRow, int initialColumn) {;}
public void addRow(DataRow row);
public void appendTable(DataTable tableToCombine);
}
同樣,DataTable 因為有 DataRow 可用,所以寫來很快。而那個 appendTable 在測試的時候,不斷的去 refactor DataRowTest 和 DataRow 的 appendRow() method,以符合 DataTable 的需求 (比如說同樣的 column 數才能合併之類的條件)
從小到大的撰寫,正常來講除非設計的很完善,知道每個小元件該做什麼,才比較好實施,不然常常寫到大的部份才要重翻會瘋掉... 通常 "常識"之類的設計需求,例如本例中的 Table,大多數的狀況 Table 都是由 row 和 cell 組成的;又或者是車子,大部份都有 engine、輪胎等 parts ... 像這樣子的比較適合吧,因為這種架構都很固定了。
回到正題.... test-first 在這裡也發揮了功能: 我寫完這 DataCell, DataRow, DataTable 時,
完全沒有做任何的 debug ! 每一個 class 都是建立在完整測試好的子元件上。當 DataRow 的 appendRow() 不夠 Table的 appendTable 用時,先增加/修改 DataRowTest 的 assertion,然後再修改 DataRow 直到綠燈亮為止。這樣確保 appendTable 去call appendRow時一定不會出錯,也就是撰寫 test 永遠比 refactoring 先做。我雖然不知道如果沒寫 test 的話會發生什麼奇怪的臭蟲,不過這種不用 debug 就寫完程式,這可是寫程式以來還沒遇過的事啊~~~~ 而且完成度還很高 (有一堆 test 掛保證 :-)
不論是 bottom-up, top-down, test first 都有地方可以發揮功能。top-down 需要用點小技巧來做 isolation,通常寫完之後會留下一堆 mock 或 dummy class ,而 bottom-up 比較不會,但是需要事先良好的設計。而且等到子元件越疊越大時,還是得做 mock,不然建立 fixture 的時候會吐血 (也可以用 Object Mother pattern 來解決啦)
Conclusion....
I have been test infected !