### 添加更多測試
現在,我們應該繼續添加更多測試。我遵循的風格是:觀察class該做的所有事情,然后針對任何一項功能的任何一種可能失敗情況,進行測試。這不同于某些程序 員提倡的「測試所有public函數」。記住,測試應該是一種風險驅動(risk driven)行為,測試的目的是希望找出現在或未來可能出現的錯誤。所以我不會去測試那些僅僅讀或寫一個值域的訪問函數(accessors),因為它們太簡單了,不大可能出錯。
這一點很重要,因為如果你撰寫過多測試,結果往往測試量反而不夠。我常常閱讀許多測試相關書籍,我的反應是:測試需要做那么多工作,令我退避三舍。這種書起不了預期效果,因為它讓你覺得測試有大量工作要做。事實上,哪怕只做一點點測試,你也能從中受益。測試的要訣是:測試你最擔心出錯的部分。這樣你就能從測試工作中得到最大利益。
TIP:編寫未臻完善的測試并實際運行,好過對完美測試的無盡等待。
現在,我的目光落到了read()。它還應該做些什么?文檔上說,當input stream到達文件尾端,應該返回-1 (在我看來這并不是個很好的協議,不過我猜這會讓C程序員倍感親切)。讓我們來測試一下。我的文本編輯器告訴我,我的測試文件共有141個字符,于是我撰寫測試代碼如下:
~~~
public void testReadAtEnd() throws IOException {
int ch = -1234;
for (int i = 0; i < 141; i++)
ch = _input.read();
assertEquals(-1, ch);
}
~~~
為了讓這個測試運行起來,我必須把它添加到test suit(測試套件)中:
~~~
public static Test suite() {
TestSuite suite= new TestSuite();
suite.addTest(new FileReaderTester("testRead"));
suite.addTest(new FileReaderTester("testReadAtEnd"));
return suite;
}
~~~
當test suit (測試套件)運行起來,它會告訴我它的每個成分——也就是這兩個test cases (測試用例)——的運行情況。每個用例都會調用tearDown(),然后執行測試代碼,最終調用tearDown()。每次測試都調用setUp()和tearDown()是很重要的,因為這樣才能保證測試之間彼此隔離。也就是說我們可以按任意順序運行它們,不會對它們的結果造成任何影響。
老要記住將test cases添加到suite(),實在是件痛苦的事。幸運的是Erich Gamma和Kent Beck和我一樣懶,所以他們提供了一條途徑來避免這種痛苦。TestSuite class有個特殊構造函數,接受一個class為參數,創建出來的test suite會將該class內所有以"test"起頭的函數都當作test cases包含進來。如果遵循這一命名習慣, 就可以把我的main()改為這樣:
~~~
public static void main (String[] args) {
junit.textui.TestRunner.run (new TestSuite(FileReaderTester.class));
}
~~~
這樣,我寫的每一個測試函數便都被自動添加到test suit 中。
測試的一項重要技巧就是「尋找邊界條件」。對read()而言,邊界條件應該是第一個字符、最后一個字符、倒數第二個字符:
~~~
public void testReadBoundaries()throwsIOException {
assertEquals("read first char",'B', _input.read());
int ch;
for (int i = 1;i <140; i++)
ch = _input.read();
assertEquals("read last char",'6',_input.read());
assertEquals("read at end",-1,_input.read());
}
~~~
你可以在assertions中加入一條消息。如果測試失敗,這條消息就會被顯示出來。
TIP:考慮可能出錯的邊界條件,把測試火力集中在那兒。
「尋找邊界條件」也包括尋找特殊的、可能導致測試失敗的情況。對于文件相關測 試,空文件是個不錯的邊界條件:
~~~
public void testEmptyRead()throws IOException {
File empty = new File ("empty.txt");
FileOutputStream out = new FileOutputStream (empty);
out.close();
FileReader in = new FileReader (empty);
assertEquals (-1, in.read());
}
~~~
現在我為這個測試產生一些額外的訪對test fixture (測試裝備)。如果以后還需要空文件,我可以把這些代碼移至setUp(),從而將「空文件」加入常規test fixture。
~~~
protected void setUp(){
try {
_input = new FileReader("data.txt");
_empty = newEmptyFile();
} catch(IOException e){
throw new RuntimeException(e.toString());
}
}
private FileReader newEmptyFile() throws IOException {
File empty = new File ("empty.txt");
FileOutputStream out = new FileOutputStream(empty);
out.close();
return newFileReader(empty);
}
public void testEmptyRead() throws IOException {
assertEquals (-1, _empty.read());
}
~~~
如果讀取文件末尾之后的位置,會發生什么事?同樣應該返回-1。現在我再加一個測試來探測這一點:
~~~
public void testReadBoundaries()throwsIOException {
assertEquals("read first char",'B', _input.read());
int ch;
for (int i = 1;i <140; i++)
ch = _input.read();
assertEquals("read last char", '6', _input.read());
assertEquals("read at end",-1,_input.read());
assertEquals ("readpast end", -1, _input.read());
}
~~~
注意,我在這里扮演「程序公敵」的角色。我積極思考如何破壞代碼。我發現這種思維能夠提高生產力,并且很有趣。它縱容了我心智中比較促狹的那一部分。
測試時,別忘了檢查預期的錯誤是否如期出現。如果你嘗試在stream被關閉后再讀 取它,就應該得到一個IOException異常,這也應該被測試出來:
~~~
public void testReadAfterClose() throwsIOException{
_input.close();
try {
_input.read();
fail ("no exception for read past end");
} catch (IOException io) {}
}
~~~
IOException之外的任何異常都將以一般方式形成一個錯誤。
TIP: 當事情被大家認為應該會出錯時,別忘了檢查彼時是否有異常如預期般地被拋出。
請遵循這些規則,不斷豐富你的測試。對于某些比較復雜的,可能你得花費 一些時間來瀏覽其接口,但是在此過程中你可以真正理解這個接口。而且這對于考慮錯誤情況和邊界情況特別有幫助。這是在編寫代碼的同時(甚至之前)編寫測試代碼的另一個好處。
隨著tester classes愈來愈多,你可以產生另一個class,專門用來包含「由其他tester classes所形成」的測試套件(test suite)。這很容易做到,因為一個測試套件本來就可以包含其他測試套件。這樣,你就可以擁有一個「主控的」(master)test class:
~~~
class MasterTester extends TestCase {
public static void main (String[] args) {
junit.textui.TestRunner.run (suite());
}
public static Test suite() {
TestSuite result = new TestSuite();
result.addTest(new TestSuite(FileReaderTester.class));
result.addTest(new TestSuite(FileWriterTester.class));
// and so on...
return result;
}
}
~~~
什么時候應該停下來?我相信這樣的話你聽過很多次:「任何測試都不能證明一個程序沒有臭蟲」。這是真的,但這不會影響「測試可以提高編程速度」。我曾經見過數種測試規則建議,其目的都是保證你能夠測試所有情況的一切組合。這些東西值得一看,但是別讓它們影響你。當測試數量達到一定程度之后,測試效益就會呈現遞減態勢,而非持續遞增;如果試圖編寫太多測試,你也可能因為工作量太大而氣餒,最后什么都寫不成。你應該把測試集中在可能出錯的地方。觀 察代碼,看哪兒變得復雜;觀察函數,思考哪些地方可能出錯。是的,你的測試不可能找出所有臭蟲,但一旦進行重構,你可以更好地理解整個程序,從而找到更多臭蟲。雖然我總是以單獨一個測試套件開始重構,但前進途中我總會加入更多測試。
TIP:不要因為「測試無法捕捉所有臭蟲」,就不撰寫測試代碼,因為測試的確可以描捉到大多數臭蟲。
對象技術有個微妙處:繼承(inheritance)和多態(polymorphism )會讓測試變得比較困難,因為將有許多種組合需要測試。如果你有3個彼此合作的abstract classes ,每個abstract classes 有三個subclasses,那么你總共擁有九個可供選擇的classes,和27種組合。我并不總是試著測試所有可能組合,但我會盡量測試每一個classes,這可以大大減少各種組合所造成的風險。如果這些classes之間彼此有合理的獨立性,我很可能不會嘗試所有組合。是的,我總有可能遺漏些什么,但我覺得「花合理時間抓出大多數臭蟲」要好過「窮盡一生抓出所有臭蟲」。
測試代碼和產品代碼(待測代碼)之間有個區別:你可以放心地拷貝、編輯測試代 碼。處理多種組合情況以及面對多個可供選擇的classes時,我經常這么做。首先測試「標準發薪過程」,然后加上「資歷」和「年底前停薪」條件,然后又去掉這兩個條件……。只要在合理的測試裝備(test fixture)上準備好一些簡單的替換樣本,我就能夠很快生成不同的test case (測試用例),然后就可以利用重構手法分解出真正常用的各種東西。
我希望這一章能夠讓你對于「撰寫測試代碼」有一些感覺。關于這個主題,我可以說上很多,但如果那么做,就有點喧賓奪主了。總而言之,請構筑一個良好的臭蟲檢測器(bug detector)并經常運行它;這對任何開發工作都是一個美好的工具,并且是重構的前提。
- 譯序 by 侯捷
- 譯序 by 熊節
- 序言
- 前言
- 章節一 重構,第一個案例
- 起點
- 重構的第一步
- 分解并重組statement()
- 運用多態(Polymorphism)取代與價格相關的條件邏輯
- 結語
- 章節二 重構原則
- 何謂重構
- 為何重構
- 「重構」助你找到臭蟲(bugs)
- 何時重構
- 怎么對經理說?
- 重構的難題
- 重構與設計
- 重構與性能(Performance)
- 重構起源何處?
- 章節三 代碼的壞味道
- Duplicated Code(重復的代碼)
- Long Method(過長函數)
- Large Class(過大類)
- Long Parameter List(過長參數列)
- Divergent Change(發散式變化)
- Shotgun Surgery(散彈式修改)
- Feature Envy(依戀情結)
- Data Clumps(數據泥團)
- Primitive Obsession(基本型別偏執)
- Switch Statements(switch驚悚現身)
- Parallel Inheritance Hierarchies(平行繼承體系)
- Lazy Class(冗贅類)
- Speculative Generality(夸夸其談未來性)
- Temporary Field(令人迷惑的暫時值域)
- Message Chains(過度耦合的消息鏈)
- Middle Man(中間轉手人)
- Inappropriate Intimacy(狎昵關系)
- Alternative Classes with Different Interfaces(異曲同工的類)
- Incomplete Library Class(不完美的程序庫類)
- Data Class(純稚的數據類)
- Refused Bequest(被拒絕的遺贈)
- Comments(過多的注釋)
- 章節四 構筑測試體系
- 自我測試代碼的價值
- JUnit測試框架
- 添加更多測試
- 章節五 重構名錄
- 重構的記錄格式
- 尋找引用點
- 這些重構準則有多成熟
- 章節六 重新組織你的函數
- Extract Method(提煉函數)
- Inline Method(將函數內聯化)
- Inline Temp(將臨時變量內聯化)
- Replace Temp with Query(以查詢取代臨時變量)
- Introduce Explaining Variable(引入解釋性變量)
- Split Temporary Variable(剖解臨時變量)
- Remove Assignments to Parameters(移除對參數的賦值動作)
- Replace Method with Method Object(以函數對象取代函數)
- Substitute Algorithm(替換你的算法)
- 章節七 在對象之間搬移特性
- Move Method(搬移函數)
- Move Field(搬移值域)
- Extract Class(提煉類)
- Inline Class(將類內聯化)
- Hide Delegate(隱藏「委托關系」)
- Remove Middle Man(移除中間人)
- Introduce Foreign Method(引入外加函數)
- Introduce Local Extension(引入本地擴展)
- 章節八 重新組織數據
- Self Encapsulate Field(自封裝值域)
- Replace Data Value with Object(以對象取代數據值)
- Change Value to Reference(將實值對象改為引用對象)
- Replace Array with Object(以對象取代數組)
- Replace Array with Object(以對象取代數組)
- Duplicate Observed Data(復制「被監視數據」)
- Change Unidirectional Association to Bidirectional(將單向關聯改為雙向)
- Change Bidirectional Association to Unidirectional(將雙向關聯改為單向)
- Replace Magic Number with Symbolic Constant(以符號常量/字面常量取代魔法數)
- Encapsulate Field(封裝值域)
- Encapsulate Collection(封裝群集)
- Replace Record with Data Class(以數據類取代記錄)
- Replace Type Code with Class(以類取代型別碼)
- Replace Type Code with Subclasses(以子類取代型別碼)
- Replace Type Code with State/Strategy(以State/strategy 取代型別碼)
- Replace Subclass with Fields(以值域取代子類)
- 章節九 簡化條件表達式
- Decompose Conditional(分解條件式)
- Consolidate Conditional Expression(合并條件式)
- Consolidate Duplicate Conditional Fragments(合并重復的條件片段)
- Remove Control Flag(移除控制標記)
- Replace Nested Conditional with Guard Clauses(以衛語句取代嵌套條件式)
- Replace Conditional with Polymorphism(以多態取代條件式)
- Introduce Null Object(引入Null 對象)
- Introduce Assertion(引入斷言)
- 章節十一 處理概括關系
- Pull Up Field(值域上移)
- Pull Up Method(函數上移)
- Pull Up Constructor Body(構造函數本體上移)
- Push Down Method(函數下移)
- Push Down Field(值域下移)
- Extract Subclass(提煉子類)
- Extract Superclass(提煉超類)
- Extract Interface(提煉接口)
- Collapse Hierarchy(折疊繼承關系)
- Form Template Method(塑造模板函數)
- Replace Inheritance with Delegation(以委托取代繼承)
- Replace Delegation with Inheritance(以繼承取代委托)
- 章節十二 大型重構
- 這場游戲的本質
- Tease Apart Inheritance(梳理并分解繼承體系)
- Convert Procedural Design to Objects(將過程化設計轉化為對象設計)
- Separate Domain from Presentation(將領域和表述/顯示分離)
- Extract Hierarchy(提煉繼承體系)
- 章節十三 重構,復用與現實
- 現實的檢驗
- 為什么開發者不愿意重構他們的程序?
- 現實的檢驗(再論)
- 重構的資源和參考資料
- 從重構聯想到軟件復用和技術傳播
- 結語
- 參考文獻
- 章節十四 重構工具
- 使用工具進行重構
- 重構工具的技術標準(Technical Criteria )
- 重構工具的實用標準(Practical Criteria )
- 小結
- 章節十五 集成
- 參考書目