本文主要介紹了如何使用 JUnit 4 提供的各種功能開展有效的單元測試,并通過一個執行個體示範了如何使用 Ant 執行自動化的單元測試。本文假設讀者對 Eclipse 下進行 Java 開發有一定的經驗,并了解 Java 5 中的注解(annotation)特性。
引言
毋庸置疑,程式員要對自己編寫的代碼負責,您不僅要保證它能通過編譯,正常地運作,而且要滿足需求和設計預期的效果。單元測試正是驗證代碼行為是否滿足預期的有效手段之一。但不可否認,做測試是件很枯燥無趣的事情,而一遍又一遍的測試則更是讓人生畏的工作。幸運的是,單元測試工具 JUnit 使這一切變得簡單藝術起來。
JUnit 是 Java 社群中知名度最高的單元測試工具。它誕生于 1997 年,由 Erich Gamma 和 Kent Beck 共同開發完成。其中 Erich Gamma 是經典著作《設計模式:可複用面向對象軟體的基礎》一書的作者之一,并在 Eclipse 中有很大的貢獻;Kent Beck 則是一位極限程式設計(XP)方面的專家和先驅。
麻雀雖小,五髒俱全。JUnit 設計的非常小巧,但是功能卻非常強大。Martin Fowler 如此評價 JUnit:在軟體開發領域,從來就沒有如此少的代碼起到了如此重要的作用。它大大簡化了開發人員執行單元測試的難度,特别是 JUnit 4 使用 Java 5 中的注解(annotation)使測試變得更加簡單。
JUnit 4 初體驗
在開始體驗 JUnit 4 之前,我們需要以下軟體的支援:
- Eclipse:最為流行的 IDE,它全面內建了 JUnit,并從版本 3.2 開始支援 JUnit 4。當然 JUnit 并不依賴于任何 IDE。您可以從 http://www.eclipse.org/ 上下載下傳最新的 Eclipse 版本。
- Ant:基于 Java 的開源建構工具,您可以在 http://ant.apache.org/ 上得到最新的版本和豐富的文檔。Eclipse 中已經內建了 Ant,但是在撰寫本文時,Eclipse 使用的 Ant 版本較低(必需 1.7 或者以上版本),不能很好的支援 JUnit 4。
- JUnit:它的官方網站是 http://www.junit.org/。您可以從上面擷取關于 JUnit 的最新消息。如果您和本文一樣在 Eclipse 中使用 JUnit,就不必再下載下傳了。
首先為我們的體驗建立一個 Java 工程 —— coolJUnit。現在需要做的是,打開項目 coolJUnit 的屬性頁 -> 選擇“Java Build Path”子選項 -> 點選“Add Library…”按鈕 -> 在彈出的“Add Library”對話框中選擇 JUnit(圖1),并在下一頁中選擇版本 4.1 後點選“Finish”按鈕。這樣便把 JUnit 引入到目前項目庫中了。
圖1 為項目添加 JUnit 庫
可以開始編寫單元測試了嗎?等等……,您打算把單元測試代碼放在什麼地方呢?把它和被測試代碼混在一起,這顯然會照成混亂,因為單元測試代碼是不會出現在最終産品中的。建議您分别為單元測試代碼與被測試代碼建立單獨的目錄,并保證測試代碼和被測試代碼使用相同的包名。這樣既保證了代碼的分離,同時還保證了查找的友善。遵照這條原則,我們在項目 coolJUnit 根目錄下添加一個新目錄 testsrc,并把它加入到項目源代碼目錄中(加入方式見 圖2)。
圖2 修改項目源代碼目錄
現在我們得到了一條 JUnit 的最佳實踐:單元測試代碼和被測試代碼使用一樣的包,不同的目錄。
一切準備就緒,一起開始體驗如何使用 JUnit 進行單元測試吧。下面的例子來自筆者的開發實踐:工具類 WordDealUtil 中的靜态方法 wordFormat4DB 是專用于處理 Java 對象名稱向資料庫表名轉換的方法(您可以在代碼注釋中可以得到更多詳細的内容)。下面是第一次編碼完成後大緻情形:
package com.ai92.cooljunit; import java.util.regex.Matcher; import java.util.regex.Pattern; public class WordDealUtil { public static String wordFormat4DB(String name){ Pattern p = Pattern.compile("[A-Z]"); Matcher m = p.matcher(name); StringBuffer sb = new StringBuffer(); while(m.find()){ m.appendReplacement(sb, "_"+m.group()); } return m.appendTail(sb).toString().toLowerCase(); } } |
它是否能按照預期的效果執行呢?嘗試為它編寫 JUnit 單元測試代碼如下:
package com.ai92.cooljunit; import static org.junit.Assert.assertEquals; import org.junit.Test; public class TestWordDealUtil { //測試wordFormat4DB正常運作的情況 @Test public void wordFormat4DBNormal(){ String target = "employeeInfo"; String result = WordDealUtil.wordFormat4DB(target); assertEquals("employee_info", result); } } |
很普通的一個類嘛!測試類 TestWordDealUtil 之是以使用“Test”開頭,完全是為了更好的區分測試類與被測試類。測試方法 wordFormat4DBNormal 調用執行被測試方法 WordDealUtil.wordFormat4DB,以判斷運作結果是否達到設計預期的效果。需要注意的是,測試方法 wordFormat4DBNormal 需要按照一定的規範書寫:
- 測試方法必須使用注解 org.junit.Test 修飾。
- 測試方法必須使用 public void 修飾,而且不能帶有任何參數。
測試方法中要處理的字元串為“employeeInfo”,按照設計目的,處理後的結果應該為“employee_info”。assertEquals 是由 JUnit 提供的一系列判斷測試結果是否正确的靜态斷言方法(位于類 org.junit.Assert 中)之一,我們使用它将執行結果 result 和預期值“employee_info”進行比較,來判斷測試是否成功。
看看運作結果如何。在測試類上點選右鍵,在彈出菜單中選擇 Run As JUnit Test。運作結果如下圖所示:
圖3 JUnit 運作成功界面
綠色的進度條提示我們,測試運作通過了。但現在就宣布代碼通過了單元測試還為時過早。記住:您的單元測試代碼不是用來證明您是對的,而是為了證明您沒有錯。是以單元測試的範圍要全面,比如對邊界值、正常值、錯誤值得測試;對代碼可能出現的問題要全面預測,而這也正是需求分析、詳細設計環節中要考慮的。顯然,我們的測試才剛剛開始,繼續補充一些對特殊情況的測試:
public class TestWordDealUtil { …… //測試 null 時的處理情況 @Test public void wordFormat4DBNull(){ String target = null; String result = WordDealUtil.wordFormat4DB(target); assertNull(result); } //測試空字元串的處理情況 @Test public void wordFormat4DBEmpty(){ String target = ""; String result = WordDealUtil.wordFormat4DB(target); assertEquals("", result); } //測試當首字母大寫時的情況 @Test public void wordFormat4DBegin(){ String target = "EmployeeInfo"; String result = WordDealUtil.wordFormat4DB(target); assertEquals("employee_info", result); } //測試當尾字母為大寫時的情況 @Test public void wordFormat4DBEnd(){ String target = "employeeInfoA"; String result = WordDealUtil.wordFormat4DB(target); assertEquals("employee_info_a", result); } //測試多個相連字母大寫時的情況 @Test public void wordFormat4DBTogether(){ String target = "employeeAInfo"; String result = WordDealUtil.wordFormat4DB(target); assertEquals("employee_a_info", result); } } |
再次運作測試。很遺憾,JUnit 運作界面提示我們有兩個測試情況未通過測試(圖4)——當首字母大寫時得到的處理結果與預期的有偏差,造成測試失敗(failure);而當測試對 null 的處理結果時,則直接抛出了異常——測試錯誤(error)。顯然,被測試代碼中并沒有對首字母大寫和 null 這兩種特殊情況進行處理,修改如下:
//修改後的方法wordFormat4DB public static String wordFormat4DB(String name){ if(name == null){ return null; } Pattern p = Pattern.compile("[A-Z]"); Matcher m = p.matcher(name); StringBuffer sb = new StringBuffer(); while(m.find()){ if(m.start() != 0) m.appendReplacement(sb, ("_"+m.group()).toLowerCase()); } return m.appendTail(sb).toString().toLowerCase(); } |
圖4 JUnit 運作失敗界面
JUnit 将測試失敗的情況分為兩種:failure 和 error。Failure 一般由單元測試使用的斷言方法判斷失敗引起,它表示在測試點發現了問題;而 error 則是由代碼異常引起,這是測試目的之外的發現,它可能産生于測試代碼本身的錯誤(測試代碼也是代碼,同樣無法保證完全沒有缺陷),也可能是被測試代碼中的一個隐藏的bug。
啊哈,再次運作測試,綠條又重制眼前。通過對 WordDealUtil.wordFormat4DB 比較全面的單元測試,現在的代碼已經比較穩定,可以作為 API 的一部分提供給其它子產品使用了。
不知不覺中我們已經使用 JUnit 漂亮的完成了一次單元測試。可以體會到 JUnit 是多麼輕量級,多麼簡單,根本不需要花心思去研究,這就可以把更多的注意力放在更有意義的事情上——編寫完整全面的單元測試。
JUnit 深入
當然,JUnit 提供的功能決不僅僅如此簡單,在接下來的内容中,我們會看到 JUnit 中很多有用的特性,掌握它們對您靈活的編寫單元測試代碼非常有幫助。
Fixture
何謂 Fixture?它是指在執行一個或者多個測試方法時需要的一系列公共資源或者資料,例如測試環境,測試資料等等。在編寫單元測試的過程中,您會發現在大部分的測試方法在進行真正的測試之前都需要做大量的鋪墊——為設計準備 Fixture 而忙碌。這些鋪墊過程占據的代碼往往比真正測試的代碼多得多,而且這個比率随着測試的複雜程度的增加而遞增。當多個測試方法都需要做同樣的鋪墊時,重複代碼的“壞味道”便在測試代碼中彌漫開來。這股“壞味道”會弄髒您的代碼,還會因為疏忽造成錯誤,應該使用一些手段來根除它。
JUnit 專門提供了設定公共 Fixture 的方法,同一測試類中的所有測試方法都可以共用它來初始化 Fixture 和登出 Fixture。和編寫 JUnit 測試方法一樣,公共 Fixture 的設定也很簡單,您隻需要:
- 使用注解 org,junit.Before 修飾用于初始化 Fixture 的方法。
- 使用注解 org.junit.After 修飾用于登出 Fixture 的方法。
- 保證這兩種方法都使用 public void 修飾,而且不能帶有任何參數。
遵循上面的三條原則,編寫出的代碼大體是這個樣子:
//初始化Fixture方法 @Before public void init(){……} //登出Fixture方法 @After public void destroy(){……} |
這樣,在每一個測試方法執行之前,JUnit 會保證 init 方法已經提前初始化測試環境,而當此測試方法執行完畢之後,JUnit 又會調用 destroy 方法登出測試環境。注意是每一個測試方法的執行都會觸發對公共 Fixture 的設定,也就是說使用注解 Before 或者 After 修飾的公共 Fixture 設定方法是方法級别的(圖5)。這樣便可以保證各個獨立的測試之間互不幹擾,以免其它測試代碼修改測試環境或者測試資料影響到其它測試代碼的準确性。
圖5 方法級别 Fixture 執行示意圖
可是,這種 Fixture 設定方式還是引來了批評,因為它效率低下,特别是在設定 Fixture 非常耗時的情況下(例如設定資料庫連結)。而且對于不會發生變化的測試環境或者測試資料來說,是不會影響到測試方法的執行結果的,也就沒有必要針對每一個測試方法重新設定一次 Fixture。是以在 JUnit 4 中引入了類級别的 Fixture 設定方法,編寫規範如下:
- 使用注解 org,junit.BeforeClass 修飾用于初始化 Fixture 的方法。
- 使用注解 org.junit.AfterClass 修飾用于登出 Fixture 的方法。
- 保證這兩種方法都使用 public static void 修飾,而且不能帶有任何參數。
類級别的 Fixture 僅會在測試類中所有測試方法執行之前執行初始化,并在全部測試方法測試完畢之後執行登出方法(圖6)。代碼範本如下:
//類級别Fixture初始化方法 @BeforeClass public static void dbInit(){……} //類級别Fixture登出方法 @AfterClass public static void dbClose(){……} |
圖6 類級别 Fixture 執行示意圖
異常以及時間測試
注解 org.junit.Test 中有兩個非常有用的參數:expected 和 timeout。參數 expected 代表測試方法期望抛出指定的異常,如果運作測試并沒有抛出這個異常,則 JUnit 會認為這個測試沒有通過。這為驗證被測試方法在錯誤的情況下是否會抛出預定的異常提供了便利。舉例來說,方法 supportDBChecker 用于檢查使用者使用的資料庫版本是否在系統的支援的範圍之内,如果使用者使用了不被支援的資料庫版本,則會抛出運作時異常 UnsupportedDBVersionException。測試方法 supportDBChecker 在資料庫版本不支援時是否會抛出指定異常的單元測試方法大體如下:
@Test(expected=UnsupportedDBVersionException.class) public void unsupportedDBCheck(){ …… } |
注解 org.junit.Test 的另一個參數 timeout,指定被測試方法被允許運作的最長時間應該是多少,如果測試方法運作時間超過了指定的毫秒數,則JUnit認為測試失敗。這個參數對于性能測試有一定的幫助。例如,如果解析一份自定義的 XML 文檔花費了多于 1 秒的時間,就需要重新考慮 XML 結構的設計,那單元測試方法可以這樣來寫:
@Test(timeout=1000) public void selfXMLReader(){ …… } |
忽略測試方法
JUnit 提供注解 org.junit.Ignore 用于暫時忽略某個測試方法,因為有時候由于測試環境受限,并不能保證每一個測試方法都能正确運作。例如下面的代碼便表示由于沒有了資料庫連結,提示 JUnit 忽略測試方法 unsupportedDBCheck:
@ Ignore(“db is down”) @Test(expected=UnsupportedDBVersionException.class) public void unsupportedDBCheck(){ …… } |
但是一定要小心。注解 org.junit.Ignore 隻能用于暫時的忽略測試,如果需要永遠忽略這些測試,一定要确認被測試代碼不再需要這些測試方法,以免忽略必要的測試點。
測試運作器
又一個新概念出現了——測試運作器,JUnit 中所有的測試方法都是由它負責執行的。JUnit 為單元測試提供了預設的測試運作器,但 JUnit 并沒有限制您必須使用預設的運作器。相反,您不僅可以定制自己的運作器(所有的運作器都繼承自 org.junit.runner.Runner),而且還可以為每一個測試類指定使用某個具體的運作器。指定方法也很簡單,使用注解 org.junit.runner.RunWith 在測試類上顯式的聲明要使用的運作器即可:
@RunWith(CustomTestRunner.class) public class TestWordDealUtil { …… } |
顯而易見,如果測試類沒有顯式的聲明使用哪一個測試運作器,JUnit 會啟動預設的測試運作器執行測試類(比如上面提及的單元測試代碼)。一般情況下,預設測試運作器可以應對絕大多數的單元測試要求;當使用 JUnit 提供的一些進階特性(例如即将介紹的兩個特性)或者針對特殊需求定制 JUnit 測試方式時,顯式的聲明測試運作器就必不可少了。
測試套件
在實際項目中,随着項目進度的開展,單元測試類會越來越多,可是直到現在我們還隻會一個一個的單獨運作測試類,這在實際項目實踐中肯定是不可行的。為了解決這個問題,JUnit 提供了一種批量運作測試類的方法,叫做測試套件。這樣,每次需要驗證系統功能正确性時,隻執行一個或幾個測試套件便可以了。測試套件的寫法非常簡單,您隻需要遵循以下規則:
- 建立一個空類作為測試套件的入口。
- 使用注解 org.junit.runner.RunWith 和 org.junit.runners.Suite.SuiteClasses 修飾這個空類。
- 将 org.junit.runners.Suite 作為參數傳入注解 RunWith,以提示 JUnit 為此類使用套件運作器執行。
- 将需要放入此測試套件的測試類組成數組作為注解 SuiteClasses 的參數。
- 保證這個空類使用 public 修飾,而且存在公開的不帶有任何參數的構造函數。
package com.ai92.cooljunit; import org.junit.runner.RunWith; import org.junit.runners.Suite; …… @RunWith(Suite.class) @Suite.SuiteClasses({TestWordDealUtil.class}) public class RunAllUtilTestsSuite { } |
上例代碼中,我們将前文提到的測試類 TestWordDealUtil 放入了測試套件 RunAllUtilTestsSuite 中,在 Eclipse 中運作測試套件,可以看到測試類 TestWordDealUtil 被調用執行了。測試套件中不僅可以包含基本的測試類,而且可以包含其它的測試套件,這樣可以很友善的分層管理不同子產品的單元測試代碼。但是,您一定要保證測試套件之間沒有循環包含關系,否則無盡的循環就會出現在您的面前……。
參數化測試
回顧一下我們在小節“JUnit 初體驗”中舉的執行個體。為了保證單元測試的嚴謹性,我們模拟了不同類型的字元串來測試方法的處理能力,為此我們編寫大量的單元測試方法。可是這些測試方法都是大同小異:代碼結構都是相同的,不同的僅僅是測試資料和期望值。有沒有更好的方法将測試方法中相同的代碼結構提取出來,提高代碼的重用度,減少複制粘貼代碼的煩惱?在以前的 JUnit 版本上,并沒有好的解決方法,而現在您可以使用 JUnit 提供的參數化測試方式應對這個問題。
參數化測試的編寫稍微有點麻煩(當然這是相對于 JUnit 中其它特性而言):
- 為準備使用參數化測試的測試類指定特殊的運作器 org.junit.runners.Parameterized。
- 為測試類聲明幾個變量,分别用于存放期望值和測試所用資料。
- 為測試類聲明一個使用注解 org.junit.runners.Parameterized.Parameters 修飾的,傳回值為 java.util.Collection 的公共靜态方法,并在此方法中初始化所有需要測試的參數對。
- 為測試類聲明一個帶有參數的公共構造函數,并在其中為第二個環節中聲明的幾個變量指派。
- 編寫測試方法,使用定義的變量作為參數進行測試。
我們按照這個标準,重新改造一番我們的單元測試代碼:
package com.ai92.cooljunit; import static org.junit.Assert.assertEquals; import java.util.Arrays; import java.util.Collection; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; import org.junit.runners.Parameterized.Parameters; @RunWith(Parameterized.class) public class TestWordDealUtilWithParam { private String expected; private String target; @Parameters public static Collection words(){ return Arrays.asList(new Object[][]{ {"employee_info", "employeeInfo"}, //測試一般的處理情況 {null, null}, //測試 null 時的處理情況 {"", ""}, //測試空字元串時的處理情況 {"employee_info", "EmployeeInfo"}, //測試當首字母大寫時的情況 {"employee_info_a", "employeeInfoA"}, //測試當尾字母為大寫時的情況 {"employee_a_info", "employeeAInfo"} //測試多個相連字母大寫時的情況 }); } public TestWordDealUtilWithParam(String expected , String target){ this.expected = expected; this.target = target; } @Test public void wordFormat4DB(){ assertEquals(expected, WordDealUtil.wordFormat4DB(target)); } } |
很明顯,代碼瘦身了。在靜态方法 words 中,我們使用二維數組來建構測試所需要的參數清單,其中每個數組中的元素的放置順序并沒有什麼要求,隻要和構造函數中的順序保持一緻就可以了。現在如果再增加一種測試情況,隻需要在靜态方法 words 中添加相應的數組即可,不再需要複制粘貼出一個新的方法出來了。
JUnit 和 Ant
随着項目的進展,項目的規模在不斷的膨脹,為了保證項目的品質,有計劃的執行全面的單元測試是非常有必要的。但單靠JUnit提供的測試套件很難勝任這項工作,因為項目中單元測試類的個數在不停的增加,測試套件卻無法動态的識别新加入的單元測試類,需要手動修改測試套件,這是一個很容易遺忘得步驟,稍有疏忽就會影響全面單元測試的覆寫率。
當然解決的方法有多種多樣,其中将 JUnit 與建構利器 Ant 結合使用可以很簡單的解決這個問題。Ant —— 備受贊譽的 Java 建構工具。它憑借出色的易用性、平台無關性以及對項目自動測試和自動部署的支援,成為衆多項目建構過程中不可或缺的獨立工具,并已經成為事實上的标準。Ant 内置了對 JUnit 的支援,它提供了兩個 Task:junit 和 junitreport,分别用于執行 JUnit 單元測試和生成測試結果報告。使用這兩個 Task 編寫建構腳本,可以很簡單的完成每次全面單元測試的任務。
不過,在使用 Ant 運作 JUnit 之前,您需要稍作一些配置。打開 Eclipse 首選項界面,選擇 Ant -> Runtime 首選項(見圖7),将 JUnit 4.1 的 JAR 檔案添加到 Classpath Tab 頁中的 Global Entries 設定項裡。記得檢查一下 Ant Home Entries 設定項中的 Ant 版本是否在 1.7.0 之上,如果不是請替換為最新版本的 Ant JAR 檔案。
圖7 Ant Runtime 首選項
剩下的工作就是要編寫 Ant 建構腳本 build.xml。雖然這個過程稍嫌繁瑣,但這是一件一勞永逸的事情。現在我們就把前面編寫的測試用例都放置到 Ant 建構腳本中執行,為項目 coolJUnit 的建構腳本添加一下内容:
<?xml version="1.0"?> <!-- ============================================= auto unittest task ai92 ========================================== --> <project name="auto unittest task" default="junit and report" basedir="."> <property name="output folder" value="bin"/> <property name="src folder" value="src"/> <property name="test folder" value="testsrc"/> <property name="report folder" value="report" /> <!-- - - - - - - - - - - - - - - - - - target: test report folder init - - - - - - - - - - - - - - - - - --> <target name="test init"> <mkdir dir="${report folder}"/> </target> <!-- - - - - - - - - - - - - - - - - - target: compile - - - - - - - - - - - - - - - - - --> <target name="compile"> <javac srcdir="${src folder}" destdir="${output folder}" /> <echo>compilation complete!</echo> </target> <!-- - - - - - - - - - - - - - - - - - target: compile test cases - - - - - - - - - - - - - - - - - --> <target name="test compile" depends="test init"> <javac srcdir="${test folder}" destdir="${output folder}" /> <echo>test compilation complete!</echo> </target> <target name="all compile" depends="compile, test compile"> </target> <!-- ======================================== target: auto test all test case and output report file ===================================== --> <target name="junit and report" depends="all compile"> <junit printsummary="on" fork="true" showoutput="true"> <classpath> <fileset dir="lib" includes="**Test*.*" /> </fileset> </batchtest> </junit> <junitreport todir="${report folder}"> <fileset dir="${report folder}"> <include name="TEST-*.xml" /> </fileset> <report format="frames" todir="${report folder}" /> </junitreport> </target> </project> |
Target junit report 是 Ant 建構腳本中的核心内容,其它 target 都是為它的執行提供前期服務。Task junit 會尋找輸出目錄下所有命名以“Test”開頭的 class 檔案,并執行它們。緊接着 Task junitreport 會将執行結果生成 HTML 格式的測試報告(圖8)放置在“report folder”下。
為整個項目的單元測試類确定一種命名風格。不僅是出于區分類别的考慮,這為 Ant 批量執行單元測試也非常有幫助,比如前面例子中的測試類都已“Test”打頭,而測試套件則以“Suite”結尾等等。
圖8 junitreport 生成的測試報告
現在執行一次全面的單元測試變得非常簡單了,隻需要運作一下 Ant 建構腳本,就可以走完所有流程,并能得到一份詳盡的測試報告。您可以在 Ant 線上手冊 中獲得上面提及的每一個 Ant 内置 task 的使用細節。
總結
随着越來越多的開發人員開始認同并接受極限程式設計(XP)的思想,單元測試的作用在軟體工程中變得越來越重要。本文旨在将最新的單元測試工具 JUnit 4 介紹給您,以及如何結合 IDE Eclipse 和建構工具 Ant 建立自動化單元測試方案。并且還期望您能夠通過本文“感染”一些好的單元測試意識,因為 JUnit 本身僅僅是一份工具而已,它的真正優勢來自于它的思想和技術。
下載下傳
描述 | 名字 | 大小 | 下載下傳方法 |
本文示例代碼 | coolJUnit.zip | 24 KB | HTTP |