TDD可以應用在多個層級上。表11-1列出了通常會採用TDD的四個測試層級。
表11-1 TDD的測試層級
BigDecimal
類中的方法
集成測試通過測試驗證類之間的交互測試Currency
類以及它如何跟BigDecimal
交互
系統測試通過測試驗證運行的系統從UI到Currency
類測試會計系統
系統集成測試通過測試驗證運行的系統,包括第三方組件測試會計系統,包括它與第三方報表系統間的交互
在單元測試中使用TDD是最容易的,如果你對TDD不熟悉,這一層就是個很好的起點。本節主要講述如何在單元測試層中使用TDD。後續章節會討論其他層級,包括第三方組件和子系統的測試。
提示 處理沒有或只有很少測試的遺留代碼是個恐怖的任務。我們幾乎不可能把所有測試都追加上,因此,應該只是為添加的新功能加上測試代碼。請參閱Michael Feathers的Working Effectively with Legacy Code1(Prentice Hall,2004)獲取更多幫助。
1 中文版《修改代碼的藝術》已由人民郵電出版社於2007年出版(更多信息請參見http://www.ituring.com.cn/book/536)。——編者注
我們一開始會簡單介紹一下TDD的基本前提——紅—綠—重構循環——用JUnit測試計算劇院門票銷售收入的代碼2。只要遵照紅—綠—重構循環,基本上就可以使用TDD!之後我們會探究一下紅—綠—重構循環背後的思想,讓你對為什麼應該採用這種技術有更清楚地認識。最後我們將介紹JUnit這個公認的Java開發者測試框架,講解它的基本用法。
2 銷售劇院門票在我的家鄉倫敦是個大生意,最起碼在我們寫這本書的時候是。
讓我們開始吧,先來一個TDD三步(紅—綠—重構)測試計算劇院門票銷售收入的實際例子。
11.1.1 一個測試用例
如果你有TDD方面的經驗,可以自行決定是否跳過這一節,不過這個小例子中有些新東西。假定有人要你寫一個堅若磐石的方法來計算劇院門票的銷售收入。劇院會計最初給出的業務規則很簡單:
- 門票的底價是30美元;
- 總收入=售出票數*價格;
- 劇院有100個座位。
因為劇院工作人員不懂軟件,所以他們現在還必須手工錄入門票的銷售數量。
如果你做過TDD,應該知道它的三個基本步驟:紅、綠、重構。如果剛接觸TDD,或者想複習一下,那就請看一下Kent Beck在《測試驅動開發》中對這些步驟的定義:
- 紅,寫一些不能用的測試代碼(失敗測試);
- 綠,盡快讓測試通過(通過測試);
- 重構,消除重複(經過細化的通過測試) 。
為了讓你瞭解TicketRevenue
應該達到什麼效果,請先看一下這些偽代碼。
estimateRevenue(int numberOfTicketsSold)
if (numberOfTicketsSold is less than 0 OR greater than 100)
then
Deal with error and exit
else
revenue = 30 * numberOfTicketsSold;
return revenue;
endif
注意,千萬別太深入。測試最終會驅動設計,也會部分影響實現。
注意 我們在11.1.2節會涉及開始失敗測試的辦法,但在這個例子中我們準備寫一個甚至還無法編譯的測試!
接下來我們先用JUnit寫一個失敗單元測試。如果你不瞭解JUnit,請跳到11.1.4節,然後再回來。
1. 編寫失敗測試(紅)
這一步的要點是以一個會失敗的測試開始。實際上,這個測試甚至無法編譯,因為你還沒有TicketRevenue
類!
在跟會計開過一個簡短的白板會議後,你意識到測試代碼需要覆蓋五種情況:售票數量為負數、0、1、2~100,還有大於100。
提示 編寫測試代碼(特別是牽扯到數值時)有一個很好的經驗法則,要考慮值為0/null、1和很多(N)的情況。再進一步考慮N上的其他限制,比如數量為負或超出上限。
我們決定先寫一個測試覆蓋銷售一張門票收入的情況。測試代碼看起來應該如代碼清單11-1所示(記住這個階段不用編寫完美的通過測試)。
代碼清單11-1 為TicketRevenue
編寫的失敗單元測試
import java.math.BigDecimal;
import static junit.framework.Assert.*;
import org.junit.Before;
import org.junit.Test;
public class TicketRevenueTest {
private TicketRevenue venueRevenue;
private BigDecimal expectedRevenue;
@Before
public void setUp {
venueRevenue = new TicketRevenue;
}
@Test
public void oneTicketSoldIsThirtyInRevenue { //銷售一張票的情況
expectedRevenue = new BigDecimal("30");
assertEquals(expectedRevenue, venueRevenue.estimateTotalRevenue(1));
}
}
測試期望銷售一張門票得到的收入等於30。
但這個測試不能編譯,因為有estimateTotalRevenue(int numberOfTicketsSold)
方法的TicketRevenue
類還不存在呢。為了運行測試,可以先隨便寫一個讓測試可以編譯的實現。
public class TicketRevenue {
public BigDecimal estimateTotalRevenue(int i) {
return BigDecimal.ZERO;
}
}
現在測試代碼能編譯了,你可以在自己喜歡的IDE中運行它。每種IDE都有自己運行JUnit測試的辦法,但一般都能在選中測試類後,從右鍵彈出菜單中選擇運行測試。一旦運行,IDE一般都會更新窗口告訴你測試失敗了,因為你所期望的30和estimateTotalRevenue(1);
返回的值不符,它的返回值是0。
失敗測試有了,接下來該做通過測試了(變綠)。
2. 編寫通過測試(綠)
這一步的要點是讓測試通過,但沒必要把實現做到完美。給TicketRevenue
類一個更好的estimateTotalRevenue
實現(不會只返回0),可以讓測試通過(變綠)。
記住,這一階段只要讓測試通過就行,沒必要追求完美。代碼可能如代碼清單11-2所示:
代碼清單11-2 第一版通過測試的TicketRevenue
import java.math.BigDecimal;
public class TicketRevenue {
public BigDecimal estimateTotalRevenue(int numberOfTicketsSold) {
BigDecimal totalRevenue = BigDecimal.ZERO;
if (numberOfTicketsSold == 1) {
totalRevenue = new BigDecimal("30"); //通過測試的實現
}
return totalRevenue;
}
}
現在再運行測試,通過了!而且在大多數IDE中,會用一個綠條或對勾來表示測試通過。圖11-1是在Eclipse中通過測試的界面。
圖11-1 Eclipse IDE中表示測試通過的綠條,紙質版印刷出來是中度灰色
接下來的問題是你能不能說「我搞定了」,然後去做下一項工作?我們可以負責任地告訴你:「不是!」你會忍不住想完善前面的代碼,那現在我們就開始吧。
3. 重構測試
這一步的要點是看看為了通過測試寫的快速實現,確保你遵循了通行的慣例。代碼清單11-2中的代碼明顯可以更清晰、更整潔。你肯定要重構,以減輕自己和他人的技術債務。
技術債務 Ward Cunningham發明的說法,指我們現在臨時湊合出來的設計或代碼將來會讓我們付出更多的成本(工作)。
記住,有了通過測試,可以放心大膽地重構。應該實現的業務邏輯不可能會被忽視。
提示 編寫最初的通過測試代碼的另一個好處是開發進度可以更快。團隊中的其他人可以馬上用第一版代碼跟更大的代碼庫一起測試(集成測試及更大範圍的測試)。
在代碼清單11-3中,我們不想再用魔法數字了——要讓票價(30)出現在代碼中。
代碼清單11-3 通過測試的TicketRevenue
重構版
import java.math.BigDecimal;
public class TicketRevenue {
private final static int TICKET_PRICE = 30;//不用魔法數字了
public BigDecimal estimateTotalRevenue(int numberOfTicketsSold) {
BigDecimal totalRevenue = BigDecimal.ZERO;
if (numberOfTicketsSold == 1) {
totalRevenue =
new BigDecimal(TICKET_PRICE *
numberOfTicketsSold); //重構的計算
}
return totalRevenue;
}
}
經過這次重構,代碼得到了改善,但很明顯它還沒有涵蓋所有情況(售票數量為負值、0、2~100和大於100)。你不能只是拚命地猜其他情況下的實現應該是什麼樣,而應該做更多測試驅動的設計和實現。下一節會繼續按照測試驅動設計的方式,帶你看更多的測試用例。
11.1.2 多個測試用例
按照TDD風格,應該繼續為門票銷售數量為負值、0、2~100和大於100的情況依次添加測試用例。但還有一種辦法,一次寫一組測試用例也行,特別是在它們跟最初的測試有關的時候。
注意,這次仍然要遵循紅—綠—重構的循環週期。在把這些用例都加上之後,你應該會得到一個帶有失敗測試(紅)的測試類,如代碼清單11-4所示。
代碼清單11-4 TicketRevenue
的失敗單元測試
import java.math.BigDecimal;
import static junit.framework.Assert.*;
import org.junit.Test;
public class TicketRevenueTest {
private TicketRevenue venueRevenue;
private BigDecimal expectedRevenue;
@Before
public void setUp {
venueRevenue = new TicketRevenue;
}
@Test(expected=IllegalArgumentException.class)
public void failIfLessThanZeroTicketsAreSold { //銷量為負值
venueRevenue.estimateTotalRevenue(-1);
}
@Test
public void zeroSalesEqualsZeroRevenue { //銷量為0
assertEquals(BigDecimal.ZERO, venueRevenue.estimateTotalRevenue(0));
}
@Test
public void oneTicketSoldIsThirtyInRevenue { //銷量為1
expectedRevenue = new BigDecimal("30");
assertEquals(expectedRevenue, venueRevenue.estimateTotalRevenue(1));
}
@Test
public void tenTicketsSoldIsThreeHundredInRevenue {
expectedRevenue = new BigDecimal("300");
assertEquals(expectedRevenue, venueRevenue.estimateTotalRevenue(10));
}
@Test(expected=IllegalArgumentException.class)
public void failIfMoreThanOneHundredTicketsAreSold { //銷量大於100
venueRevenue.estimateTotalRevenue(101);
}
}
為通過所有測試(綠)寫的基本實現版看起來應該如代碼清單11-5所示。
代碼清單11-5 通過測試的第一版TicketRevenue
import java.math.BigDecimal;
public class TicketRevenue {
public BigDecimal estimateTotalRevenue(int numberOfTicketsSold)
throws IllegalArgumentException {
BigDecimal totalRevenue = null;
if (numberOfTicketsSold < 0) {
throw new IllegalArgumentException("Must be > -1"); ﹃異常情況
}
if (numberOfTicketsSold == 0) {
totalRevenue = BigDecimal.ZERO;
}
if (numberOfTicketsSold == 1) {
totalRevenue = new BigDecimal("30");
}
if (numberOfTicketsSold == 101) {
throw new IllegalArgumentException("Must be < 101"); ﹄異常情況
}
else {
totalRevenue =
new BigDecimal(30 * numberOfTicketsSold); //銷量為N
}
return totalRevenue;
}
}
有了剛剛完成的實現,現在你的測試就變成通過測試了。
按照TDD循環週期,現在該重構這個實現了。比如說,可以把不合法的numberOfTicketsSold
情況(負數或者大於100)放到一個if
語句中,並用公式(TICKET_PRICE * numberOfTicketsSold)
返回所有合法numberOfTicketsSold
的收入。代碼清單11-6應該跟重構之後的代碼很像。
代碼清單11-6 重構後的TicketRevenue
版本
import java.math.BigDecimal;
public class TicketRevenue {
private final static int TICKET_PRICE = 30;
public BigDecimal estimateTotalRevenue(int numberOfTicketsSold)
throws IllegalArgumentException {
if (numberOfTicketsSold < 0 || numberOfTicketsSold > 100) {
throw new IllegalArgumentException
("# Tix sold must == 1..100"); //異常狀況
}
return new BigDecimal
(TICKET_PRICE * numberOfTicketsSold); //所有其他情況
}
}
新的TicketRevenue
類更加緊湊,並且還通過了所有測試!現在你已經完成了整個紅—綠—重構循環,可以信心滿滿地開始實現下一個業務邏輯了。另外,如果你(或會計)發現漏掉了任何邊界情況,比如有浮動票價,也可以再次開始一個循環。
我們強烈建議你弄明白紅—綠—重構的TDD方式背後的原理,也就是我們接下來要討論的內容。但如果你沒什麼耐心,可以直接跳到11.1.4節學習JUnit,或11.2節瞭解用來測試第三方代碼的測試替身。
11.1.3 深入思考紅—綠—重構循環
這一節會在前面例子的基礎上探索TDD背後的一些思想。我們會再次談論紅—綠—重構循環,你應該還記得第一步是寫失敗測試。但這也有幾種不同的方式。
1. 失敗測試(紅)
一些開發人員真的喜歡編寫編譯失敗的測試,喜歡等到綠色步驟才提供實現代碼。也有一些開發人員喜歡先把測試調用的方法存根寫出來,這樣雖然測試代碼能編譯,但還是會失敗。我們覺得怎麼樣都行,隨意就好。
提示 這些測試代碼是實現的第一個客戶,所以應該認真考慮該怎麼設計它們:方法定義看起來應該是什麼樣的。還應該問自己幾個問題:該傳什麼參數進去?期望的返回值是什麼?會不會有異常情況?另外,不要忘了測試重要領域對象的
equals
和hashCode
方法。
一旦寫完失敗測試,就該進入下一階段了:讓它通過。
2. 通過測試(綠)
這一步應該盡量少寫代碼,只要保證測試通過就行。也就是說你不用把實現做到完美,那是重構階段的工作。
測試通過之後,你就可以告訴同事,你的代碼已經實現了它應該實現的功能,他們可以拿去用了。
3. 重構
在這一步中應該重構實現代碼。可以重構的地方數不勝數,但有幾個應該重點關注的,比如去掉硬編碼的變量或把大方法拆分開。如果是面向對象的代碼,則應該遵循SOLID原則。
SOLID原則是Bob大叔(Robert Martin)提出來的,請參見表11-2。要瞭解更詳細的信息,可以參考他的文章「The Principles of OOD」(http://butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod)。
表11-2 面向對像代碼的SOLID原則
提示 我們還要向你推薦Checkstyle和FindBugs這兩個靜態代碼分析工具(第12章還有更多)。Joshua Bloch的Effective Java, Sencond Edition1(Addison-Wesley, 2008)也是好資源,其中有很多Java語言的技巧和竅門。
1《Effective Java中文版》由機械工業出版社於2003年出版。——編者注
測試代碼本身的重構是個容易被人遺忘的角落。你可以把通用的設置和拆卸代碼提取出來,可以重命名測試以更準確地反應它的測試意圖,還可以根據靜態分析工具的分析結果做些小修訂。
現在你已經能跟上TDD的三步走了,該去熟悉一下JUnit了,它可是在Java裡寫TDD代碼的默認工具。
11.1.4 JUnit
JUnit是公認的Java項目測試框架。當然,除了JUnit還有其他測試框架,比如擁有不少追隨者的TestNG,但目前JUnit還是Java測試界的主流。
注意 如果你熟悉JUnit,可以跳到11.2節。
JUnit有三個主要特性:
- 用於測試預期結果和異常的斷言,比如
assertEquals
; - 設置和拆卸通用測試數據的能力,比如
@Before
和@After
; - 運行測試套件的測試運行器。
JUnit用簡單的註解模型提供了很多重要的功能。
大多數IDE(比如Eclipse、IntelliJ和NetBeans)都內置了JUnit,如果你用的正好是其中之一,就不用自己去下載、安裝或配置JUnit了。如果你的IDE沒有安裝JUnit,可以訪問www.junit.org查看它的下載和安裝指導1。
1 第12章會講到JUnit和Maven的集成。
注意 我們用的是JUnit 4.8.2。如果你要練習本章中的例子,建議也用這個版本。
一個基本的JUnit測試包含下面這些元素:
- 用
@Before
標記設置方法,在每個測試運行前準備測試數據; - 用
@After
標記拆卸方法,在每個測試運行完成後拆卸測試數據; - 測試方法本身(用
@Test
註解標記)。
為了多瞭解一下上面這些元素,我們來看幾個非常基本的JUnit測試。
比如OpenJDK團隊要你給BigDecimal
類寫個單元測試。第一個測試是檢查加法(1.5 + 1.5==3.0);
,第二個測試是檢查用非數字值創建BigDecimal
實例時會拋出NumberFormatException
異常。
注意 我們在本章的例子中經常同時給出多個失敗測試,實現(綠)和重構。這違背了純粹的TDD 單個測試貫穿紅—綠—重構循環的原則,但卻可以讓我們在本章中放入更多例子。不過在你編碼時,應該盡可能地遵守單個測試循環的開發模型。
要運行代碼清單11-7,可以在IDE裡的源碼文件上點擊右鍵,選擇運行或測試選項(三個主流IDE中都有顯眼的Run Test或Run File選項)。
代碼清單11-7 JUnit測試的基本結構
import java.math.BigDecimal;
import org.junit.*;
import static org.junit.Assert.*; //標準的JUnit導入
public class BigDecimalTest {
private BigDecimal x;
@Before
public void setUp { x = new BigDecimal("1.5"); } //❶每個測試之前的設置
@After
public void tearDown { x = null; } //❷每個測試之後的拆卸
@Test
public void addingTwoBigDecimals {
assertEquals(new BigDecimal("3.0"), x.add(x));
} //❸執行測試
@Test(expected=NumberFormatException.class) //❹處理意料中的異常
public void numberFormatExceptionIfNotANumber {
x = new BigDecimal("Not a number");
}
}
在每個測試運行之前,x
在@Before
區域中被設置為BigDecimal("1.5")
❶。這會確保每個測試處理的都是已知值x
,而不是被之前運行的測試修改過的中間值。在每個測試運行之後,在@After
區域中確保x
被設為null
❷(以便x
可以被垃圾收集)。然後用assertEquals
(JUnit眾多靜態assertX
方法之一)測試BigDecimal.add
的返回結果是否符合期望❸。為了處理預期的異常,在@Test
上加上了可選的expected
參數❹。
進入TDD最佳狀態的最好辦法就是動手實踐。把TDD原則牢牢印在你的腦海裡,把JUnit框架搞明白,你就可以開始了!通過這些例子你也能看出來,單元測試級的TDD很容易掌握。
但所有TDD從業者最終都要測試使用依賴項或子系統的代碼。下一節就會講到那些代碼的測試技術。