本文通過理論分析和詳細例子向讀者闡述 JUnit 4.4 所帶來的最新特性,讀者通過本文的學習,可以輕鬆掌握使用 JUnit 4.4 的新特性。
隨著當前 Java 開發的越發成熟,Agile 和 TDD 的越發流行,自動化測試的呼聲也越來越高。若想將單元測試變得自動化,自然 JUnit 這把利器必不可少,這也是 JUnit 自 1997 年誕生以來在 Java 開發業界一直相當流行的原因。
JUnit 是針對 Java 語言的一個單元測試框架,它被認為是迄今為止所開發的最重要的第三方 Java 庫。 JUnit 的優點是整個測試過程無需人的參與,無需分析和判斷最終測試結果是否正確,而且可以很容易地一次性運行多個測試。 JUnit 的出現促進了測試的盛行,它使得 Java 代碼更健壯,更可靠,Bug 比以前更少。
JUnit 自從問世以來一直在不停的推出新版本,目前最新的版本是 2007 年 7 月發布的 JUnit 4.4,它是繼 JUnit4 以來最大的發行版,提供了很多有用的新特性。本文將假設讀者已經具有 JUnit 4 的使用經驗。
JUnit 4.4 概述
JUnit 設計的目的就是有效地抓住編程人員寫代碼的意圖,然後快速檢查他們的代碼是否與他們的意圖相匹配。 JUnit 發展至今,版本不停的翻新,但是所有版本都一致致力於解決一個問題,那就是如何發現編程人員的代碼意圖,並且如何使得編程人員更加容易地表達他們的代碼意圖。JUnit 4.4 也是為了如何能夠更好的達到這個目的而出現的。
JUnit 4.4 主要提供了以下三個大方面的新特性來更好的抓住編程人員的代碼意圖:
新的斷言語法(Assertion syntax)—— assertThat
JUnit 4.4 學習 JMock,引入了 Hamcrest 匹配機制,使得程序員在編寫單元測試的 assert 語句時,可以具有更強的可讀性,而且也更加靈活。
Hamcrest 是一個測試的框架,它提供了一套通用的匹配符 Matcher,靈活使用這些匹配符定義的規則,程序員可以更加精確的表達自己的測試思想,指定所想設定的測試條件。比如,有時候定義的測試數據範圍太精確,往往是若干個固定的確定值,這時會導致測試非常脆弱,因為接下來的測試數據只要稍稍有變化,就可能導致測試失敗(比如 assertEquals( x, 10 ); 只能判斷 x 是否等於 10,如果 x 不等於 10,測試失敗);有時候指定的測試數據範圍又不夠太精確,這時有可能會造成某些本該會導致測試不通過的數據,仍然會通過接下來的測試,這樣就會降低測試的價值。 Hamcrest 的出現,給程序員編寫測試用例提供了一套規則和方法,使用其可以更加精確的表達程序員所期望的測試的行為。(具體 Hamcrest 的使用,請參閱 參考資料)
JUnit 4.4 結合 Hamcrest 提供了一個全新的斷言語法——assertThat。程序員可以只使用 assertThat 一個斷言語句,結合 Hamcrest 提供的匹配符,就可以表達全部的測試思想。
assertThat 的基本語法如下:
assertThat( [value], [matcher statement] ); |
assertThat 的優點
// 想判斷某個字元串 s 是否含有子字元串 "developer" 或 "Works" 中間的一個 // JUnit 4.4 以前的版本:assertTrue(s.indexOf("developer")>-1||s.indexOf("Works")>-1 ); // JUnit 4.4: assertThat(s, anyOf(containsString("developer"), containsString("Works"))); // 匹配符 anyOf 表示任何一個條件滿足則成立,類似於邏輯或 "||", 匹配符 containsString 表示是否含有參數子 // 字元串,文章接下來會對匹配符進行具體介紹 |
// 聯合匹配符not和equalTo表示“不等於” assertThat( something, not( equalTo( "developer" ) ) ); // 聯合匹配符not和containsString表示“不包含子字元串” assertThat( something, not( containsString( "Works" ) ) ); // 聯合匹配符anyOf和containsString表示“包含任何一個子字元串” assertThat(something, anyOf(containsString("developer"), containsString("Works"))); |
JUnit 4.4 以前的版本默認出錯后不會拋出額外提示信息,如:
assertTrue( s.indexOf("developer") > -1 || s.indexOf("Works") > -1 ); |
如果該斷言出錯,只會拋出無用的錯誤信息,如:junit.framework.AssertionFailedError:null。
如果想在出錯時想列印出一些有用的提示信息,必須得程序員另外手動寫,如:
assertTrue( "Expected a string containing 'developer' or 'Works'", s.indexOf("developer") > -1 || s.indexOf("Works") > -1 ); |
JUnit 4.4 會默認自動提供一些可讀的描述信息,如清單 4 所示:
String s = "hello world!"; assertThat( s, anyOf( containsString("developer"), containsString("Works") ) ); // 如果出錯后,系統會自動拋出以下提示信息: java.lang.AssertionError: Expected: (a string containing "developer" or a string containing "Works") got: "hello world!" |
如何使用 assertThat
JUnit 4.4 自帶了一些 Hamcrest 的匹配符 Matcher,但是只有有限的幾個,在類 org.hamcrest.CoreMatchers 中定義,要想使用他們,必須導入包 org.hamcrest.CoreMatchers.*。
如果想使用一些其他更多的匹配符 Matcher,可以從 Hamcrest 網頁下載 hamcrest-library-1.1.jar 和 hamcrest-core-1.1.jar(請參閱 參考資料),並將其加入到工程庫中,所有的匹配符都在類 org.hamcrest.Matchers 中定義,要想使用,必須得在代碼中 import static org.hamcrest.Matchers.*;。如果使用外部的匹配符,最好就不要再使用 JUnit 4.4 自帶的匹配符了,因為這樣容易導致匹配符 Matcher 重複定義,編譯可能會出錯(ambiguous for the type)。 JUnit 4.4 允許使用 Hamcrest 來使用更多的匹配符,這還是 JUnit 第一次允許在自己的工程中使用第三方類。
注意:
清單 5 列舉了大部分 assertThat 的使用例子:
//一般匹配符 // allOf匹配符表明如果接下來的所有條件必須都成立測試才通過,相當於“與”(&&) assertThat( testedNumber, allOf( greaterThan(8), lessThan(16) ) ); // anyOf匹配符表明如果接下來的所有條件只要有一個成立則測試通過,相當於“或”(||) assertThat( testedNumber, anyOf( greaterThan(16), lessThan(8) ) ); // anything匹配符表明無論什麼條件,永遠為true assertThat( testedNumber, anything() ); // is匹配符表明如果前面待測的object等於後面給出的object,則測試通過 assertThat( testedString, is( "developerWorks" ) ); // not匹配符和is匹配符正好相反,表明如果前面待測的object不等於後面給出的object,則測試通過 assertThat( testedString, not( "developerWorks" ) ); //字元串相關匹配符 // containsString匹配符表明如果測試的字元串testedString包含子字元串"developerWorks"則測試通過 assertThat( testedString, containsString( "developerWorks" ) ); // endsWith匹配符表明如果測試的字元串testedString以子字元串"developerWorks"結尾則測試通過 assertThat( testedString, endsWith( "developerWorks" ) ); // startsWith匹配符表明如果測試的字元串testedString以子字元串"developerWorks"開始則測試通過 assertThat( testedString, startsWith( "developerWorks" ) ); // equalTo匹配符表明如果測試的testedValue等於expectedValue則測試通過,equalTo可以測試數值之間,字 //符串之間和對象之間是否相等,相當於Object的equals方法 assertThat( testedValue, equalTo( expectedValue ) ); // equalToIgnoringCase匹配符表明如果測試的字元串testedString在忽略大小寫的情況下等於 //"developerWorks"則測試通過 assertThat( testedString, equalToIgnoringCase( "developerWorks" ) ); // equalToIgnoringWhiteSpace匹配符表明如果測試的字元串testedString在忽略頭尾的任意個空格的情況下等 //於"developerWorks"則測試通過,注意:字元串中的空格不能被忽略 assertThat( testedString, equalToIgnoringWhiteSpace( "developerWorks" ) ); //數值相關匹配符 // closeTo匹配符表明如果所測試的浮點型數testedDouble在20.0±0.5範圍之內則測試通過 assertThat( testedDouble, closeTo( 20.0, 0.5 ) ); // greaterThan匹配符表明如果所測試的數值testedNumber大於16.0則測試通過 assertThat( testedNumber, greaterThan(16.0) ); // lessThan匹配符表明如果所測試的數值testedNumber小於16.0則測試通過 assertThat( testedNumber, lessThan (16.0) ); // greaterThanOrEqualTo匹配符表明如果所測試的數值testedNumber大於等於16.0則測試通過 assertThat( testedNumber, greaterThanOrEqualTo (16.0) ); // lessThanOrEqualTo匹配符表明如果所測試的數值testedNumber小於等於16.0則測試通過 assertThat( testedNumber, lessThanOrEqualTo (16.0) ); //collection相關匹配符 // hasEntry匹配符表明如果測試的Map對象mapObject含有一個鍵值為"key"對應元素值為"value"的Entry項則 //測試通過 assertThat( mapObject, hasEntry( "key", "value" ) ); // hasItem匹配符表明如果測試的迭代對象iterableObject含有元素“element”項則測試通過 assertThat( iterableObject, hasItem ( "element" ) ); // hasKey匹配符表明如果測試的Map對象mapObject含有鍵值“key”則測試通過 assertThat( mapObject, hasKey ( "key" ) ); // hasValue匹配符表明如果測試的Map對象mapObject含有元素值“value”則測試通過 assertThat( mapObject, hasValue ( "key" ) ); |
假設機制(Assumption)
理想情況下,寫測試用例的開發人員可以明確的知道所有導致他們所寫的測試用例不通過的地方,但是有的時候,這些導致測試用例不通過的地方並不是很容易的被發現,可能隱藏得很深,從而導致開發人員在寫測試用例時很難預測到這些因素,而且往往這些因素並不是開發人員當初設計測試用例時真正目的,他們的測試點是希望測試出被測代碼中別的出錯地方。
比如,一個測試用例運行的 locale(如:Locale.US)與之前開發人員設計該測試用例時所設想的不同(如:Locale.UK),這樣會導致測試不通過,但是這可能並不是開發人員之前設計測試用例時所設想的測試出來的有用的失敗結果(測試點並不是此,比如測試的真正目的是想判斷函數的返回值是否為 true,返回 false 則測試失敗),這時開發人員可以通過編寫一些額外的代碼來消除這些影響(比如將 locale 作為參數傳入到測試用例中,每次運行測試用例時,明確指定 locale),但是花費時間和精力來編寫這些不是測試用例根本目的的額外代碼其實是種浪費,這時就可以使用 Assumption 假設機制來輕鬆達到額外代碼的目的。編寫該測試用例時,首先假設 locale 必須是 Locale.UK,如果運行時 locale 是 Locale.UK,則繼續執行該測試用例函數,如果是其它的 locale,則跳過該測試用例函數,執行該測試用例函數以外的代碼,這樣就不會因為 locale 的問題導致測試出錯。
JUnit 4.4 結合 Hamcrest 庫提供了 assumeThat 語句,開發人員可以使用其配合匹配符 Matcher 設計所有的假設條件(語法和 assertThat 一樣)。同樣為了方便使用,JUnit 4.4 還專門提供了 assumeTrue,assumeNotNull 和 assumeNoException 語句。
假設機制(Assumption)的優點
優點 1:通過對 runtime 變數進行取值假設,從而不會因為一個測試用例的不通過而導致整個測試失敗而中斷(the test passes),使得測試更加連貫。
開發人員編寫單元測試時,經常會在一個測試中包含若干個測試用例函數,這時若是遇到某個測試用例函數不通過,整個單元測試就會終止。這將導致測試不連貫,因為開發人員往往希望一次能運行多個測試用例函數,不通過的測試用例函數不要影響到剩下的測試用例函數的運行,否則會給 debug 調試帶來很大的難度。
開發人員編寫單元測試時,有時是預測不了傳入到單元測試方法中的變數值的,而且這些值有時並不是開發人員所期望的,因為他們會導致測試用例不通過並中斷整個測試,所以開發人員需要跳過這些導致測試用例函數不通過的異常情況。
//@Test 註釋表明接下來的函數是 JUnit4 及其以後版本的測試用例函數 @Test public void testAssumptions() { //假設進入testAssumptions時,變數i的值為10,如果該假設不滿足,程序不會執行assumeThat後面的語句 assumeThat( i, is(10) ); //如果之前的假設成立,會列印"assumption is true!"到控制台,否則直接調出,執行下一個測試用例函數 System.out.println( "assumption is true!" ); } |
優點 2:利用假設可以控制某個測試用例的運行時間,讓其在自己期望的時候運行(run at a given time)。
@Test //測試用例函數veryLongTest()執行需要很長時間,所以開發人員不是每次都想運行它,可以通過判斷是否定義了 //”DEV”環境變數來選擇性地執行該測試用例 public void veryLongTest() throws Exception { //假設環境變數”DEV”為空,即如果之前通過System.setProperty定義過”DEV”環境變數(不為空),則自動跳過 //veryLongTest中假設后剩下的語句,去執行下一個JUnit測試用例,否則執行假設後接下來的語句 assumeThat( System.getProperty( "DEV" ), nullValue() ); System.out.println("running a long test"); Thread.sleep( 90 * 1000 ); } |
如何使用 Assumption 假設機制
開發人員可以使用 assumeThat 並配合 hamcrest 的匹配符 Matcher,對即將被傳入到單元測試用例函數中的 runtime 變數值做精確的假設,如果假設不正確(即當前 runtime 變數的取值不滿足所假設的條件),則不會將該變數傳給該測試用例中假設後面的語句,即程序會從該 assumeThat 所在的 @Test 測試函數中直接自動跳出(test automatically quietly passes,values that violate assumptions are quietly ignored),去執行下一個 @Test 函數,使得本來會中斷的測試現在不會中斷。
使用假設機制必須得注意以下幾點:
例1: @Test public void filenameIncludesString() { //如果文件分隔符不是’/’(forward slash),則不執行assertThat斷言測試,直接跳過該測試用例函數 assumeThat(File.separatorChar, is('/')); //判斷文件名fileName是否含有字元串"developerWorks" assertThat( fileName, containsString( "developerWorks" ) ); } 例2: @Test public void filenameIncludesString() { //bugFixed不是JUnit4.4的函數,是開發人員自己工程中定義的函數,表示判斷指定的defect是否 //被修正了,如果被修正,則返回true,否則返回false。這裡假設缺陷13356被修正後才進行餘下單元測試 assumeTrue( bugFixed("13356") ); //判斷文件名fileName是否含有字元串"developerWorks" assertThat( fileName, containsString( "developerWorks" ) ); } |
理論機制(Theory)
為什麼要引用理論機制(Theory)
當今軟體開發中,測試驅動開發(TDD — Test-driven development)越發流行。為什麼 TDD 會如此流行呢?因為它確實擁有很多優點,它允許開發人員通過簡單的例子來指定和表明他們代碼的行為意圖。
TDD 的優點:
然而,TDD 也同樣具有一定的局限性。對於開發人員來說,只用一些具體有限的簡單例子來表達程序的行為往往遠遠不夠。有很多代碼行為可以很容易而且精確的用語言來描述,卻很難用一些簡單的例子來表達清楚,因為他們需要大量的甚至無限的具體例子才可以達到被描述清楚的目的,而且有時有限的例子根本不能覆蓋所有的代碼行為。
以下列出的代碼行為反映了 TDD 的局限性:
理論(Theory)的出現就是為了解決 TDD 這個問題。 TDD 為組織規劃開發流程提供了一個方法,先用一些具體的例子(測試用例 test case)來描述系統代碼的行為,然後再將這些行為用代碼語句進行概括性的總的陳述(代碼實現 implementation)。而 Theory 就是對傳統的 TDD 進行一個延伸和擴展,它使得開發人員從開始的定義測試用例的階段就可以通過參數集(理論上是無限個參數)對代碼行為進行概括性的總的陳述,我們叫這些陳述為理論。理論就是對那些需要無窮個測試用例才能正確描述的代碼行為的概括性陳述。結合理論(Theory)和測試一起,可以輕鬆的描述代碼的行為並發現 BUG 。開發人員都知道他們代碼所想要實現的概括性的總的目的,理論使得他們只需要在一個地方就可以快速的指定這些目的,而不要將這些目的翻譯成大量的獨立的測試用例。
理論機制的優點
下面通過一個簡單的例子來逐步介紹理論的優點。
比如設計一個專門用來貨幣計算的計算器,首先需要給代碼行為編寫測試用例(這裡以英鎊 Pound 的乘法為例),如清單 9 所示:
@Test public void multiplyPoundsByInteger() { assertEquals( 10, new Pound(5).times(2).getAmount() ); } |
這時很自然的就會想到一個測試用例可能不夠,需要再多一個,如清單 10 所示:
@Test public void multiplyPoundsByInteger () { assertEquals( 10, new Pound(5).times(2).getAmount() ); assertEquals( 15, new Pound(5).times(3).getAmount() ); } |
但是此時您可能又會發現這兩個測試用例還是很有限,您所希望的是測試所有的整數,而不只是 2,3 和 5,這些只是您所想要的測試的數據的子集,兩個測試用例並不能完全與您所想要測試的代碼的行為相等價,您需要更多的測試用例,此時就會發現需要很多的額外工作來編寫這些測試用例,更可怕的是,您會發現您需要測試用例的並不只是簡單的幾個,可能是成千上萬個甚至無窮個測試用例才能滿足等價您的代碼行為的目的。
很自然的,您會想到用清單 11 所示的代碼來表達您的測試思想。
//利用變數來代替具體數據表達測試思想 public void multiplyAnyAmountByInteger(int amount, int multiplier) { assertEquals( amount * multiplier, new Pound( amount ).times( multiplier ).getAmount() ); } |
利用清單 11 的 multiplyAnyAmountByInteger 方法,可以輕鬆將測試用例改寫成如清單 12 所示:
@Test public void multiplyPoundsByInteger () { multiplyAnyAmountByInteger(5, 2); multiplyAnyAmountByInteger(5, 3); } |
如清單 12 所示,以後若想增加測試用例,只要不停調用 multiplyAnyAmountByInteger 方法並賦予參數值即可。
方法 multiplyAnyAmountByInteger 就是一個理論的簡單例子,理論就是一個帶有參數的方法,其行為就是對任何參數都是正常的返回,不會拋出斷言錯誤和其它異常。理論就是對一組數據進行概括性的陳述,就像一個科學理論一樣,如果沒有對所有可能出現的情況都進行實驗,是不能證明該理論是正確的,但是只要有一種錯誤情況出現,該理論就不成立。相反地,一個測試就是對一個單獨數據的單獨陳述,就像是一個科學理論的實驗一樣。
如何使用理論機制
在 JUnit 4.4 的理論機制中,每個測試方法不再是由註釋 @Test 指定的無參測試函數,而是由註釋 @Theory 指定的帶參數的測試函數,這些參數來自一個數據集(data sets),數據集通過註釋 @DataPoint 指定。
JUnit 4.4 會自動將數據集中定義的數據類型和理論測試方法定義的參數類型進行比較,如果類型相同,會將數據集中的數據通過參數一一傳入到測試方法中。數據集中的每一個數據都會被傳入到每個相同類型的參數中。這時有人會問了,如果參數有多個,而且類型都和數據集中定義的數據相同,怎麼辦?答案是,JUnit 4.4 會將這些數據集中的數據進行一一配對組合(所有的組合情況都會被考慮到),然後將這些數據組合統統通過參數,一一傳入到理論的測試方法中,但是用戶可以通過假設機制(assumption)在斷言函數(assertion)執行這些參數之前,對這些通過參數傳進來的數據集中的數據進行限制和過濾,達到有目的地部分地將自己想要的參數傳給斷言函數(assertion)來測試。只有滿足所有假設的數據才會執行接下來的測試用例,任何一個假設不滿足的數據,都會自動跳過該理論測試函數(假設 assumption 不滿足的數據會被忽略,不再執行接下來的斷言測試),如果所有的假設都滿足,測試用例斷言函數不通過才代表著該理論測試不通過。
import static org.hamcrest.Matchers.*; //指定接下來要使用的Matcher匹配符 import static org.junit.Assume.*; //指定需要使用假設assume*來輔助理論Theory import static org.junit.Assert.*; //指定需要使用斷言assert*來判斷測試是否通過 import org.junit.experimental.theories.DataPoint; //需要使用註釋@DataPoint來指定數據集 import org.junit.experimental.theories.Theories; //接下來@RunWith要指定Theories.class import org.junit.experimental.theories.Theory; //註釋@Theory指定理論的測試函數 import org.junit.runner.RunWith; //需要使用@RunWith指定接下來運行測試的類 import org.junit.Test; //注意:必須得使用@RunWith指定Theories.class @RunWith(Theories.class) public class TheoryTest { //利用註釋@DataPoint來指定一組數據集,這些數據集中的數據用來證明或反駁接下來定義的Theory理論, //testNames1和testNames2這兩個理論Theory測試函數的參數都是String,所以Junit4.4會將這5個 //@DataPoint定義的String進行兩兩組合,統統一一傳入到testNames1和testNames2中,所以參數名year //和name是不起任何作用的,"2007"同樣有機會會傳給參數name,"Works"也同樣有機會傳給參數year @DataPoint public static String YEAR_2007 = "2007"; @DataPoint public static String YEAR_2008 = "2008"; @DataPoint public static String NAME1 = "developer"; @DataPoint public static String NAME2 = "Works"; @DataPoint public static String NAME3 = "developerWorks"; //注意:使用@Theory來指定測試函數,而不是@Test @Theory public void testNames1( String year, String name ) { assumeThat( year, is("2007") ); //year必須是"2007",否則跳過該測試函數 System.out.println( year + "-" + name ); assertThat( year, is("2007") ); //這裡的斷言語句沒有實際意義,這裡舉此例只是為了不中斷測試 } //注意:使用@Theory來指定測試函數,而不是@Test @Theory public void testNames2( String year, String name ) { assumeThat(year, is("2007")); //year必須是"2007",否則跳過該測試函數 //name必須既不是"2007"也不是"2008",否則跳過該測試函數 assumeThat(name, allOf( not(is("2007")), not(is("2008")))); System.out.println( year + "-" + name ); assertThat( year, is("2007") ); //這裡的斷言語句沒有實際意義,這裡舉此例只是為了不中斷測試 } 結果輸出: 第一個Theory列印出: 2007-2007 2007-2008 2007-developer 2007-Works 2007-developerWorks 第二個Theory列印出: 2007-developer 2007-Works 2007-developerWorks |
結束語
本文通過詳細深入的理論介紹和簡單易懂的實例全面剖析了 JUnit 4.4 的三個新特性:
相信讀者看完后一定會對 JUnit 4.4 有著非常深入的了解並可以輕鬆將其運用到自己的開發工程中。(責任編輯:A6)
[火星人 ] 探索 JUnit 4.4 新特性已經有546次圍觀