Java開源測試工具JUnit簡介
1.簡介
在一篇早些的文章(請參見Test Infected: Programmers Love Writing Tests, Java Report, July 1998, Volume 3, Number 7)中,我們描述了如何使用一個簡單的框架來編寫可重復的測試。在本文中我們將匆匆一瞥其內中細節,并向你展示該框架本身是如何被構造的。
我們細致地研究JUint框架并思索如何來構造它。我們發現了許多不同層次上的教訓。在本文中,我們將嘗試著立刻與它們進行溝通,這是一個令人絕望的任務,但至少它是在我們向你展示設計和構造一件價值被證實的軟件的上下文中來進行的。
我們引發了一個關于框架目標的討論。在對框架本身的表達期間,目標將重復出現許多小的細節中。此后,我們提出框架的設計和實現。設計將從模式(驚奇,驚奇)的角度進行描述,并作為優美的程序來予以實現。我們總結了一些優秀的關于框架開發的想法。
2.什么是JUnit的目標呢?
首先,我們不得不回到開發的假定上去。如果缺少一個程序特性的自動測試(automated test),我們便假定其無法工作。這看起來要比主流的假定更加安全,主流的假定認為如果開發者向我們保證一個程序特性能夠工作,那么現在和將來其都會永遠工作。
從這個觀點來看,當開發者編寫和調試代碼時,它們的工作并沒有完成,它們還要必須編寫測試來演示程序能夠工作。然而,每個人都太忙,他們要做的事情太多,他們沒有充足的時間用于測試。我已經有太多的代碼需要編寫,要我如何再來編寫測試代碼?回答我,強硬的項目經理先生。因此,首要目標就是編寫一個框架,在這個框架中開發者能夠看到實際來編寫測試的希望之光。該框架必須要使用常見的工具,從而學習起來不會有太多的新東西。其不能比完全編寫一個新測試所必須的工作更多。必須排除重復性的工作。
如果所有測試都這樣去做的話,你將可以僅在一個調試器中編寫表達式來完成。然而,這對于測試而言尚不充分。告訴我你的程序現在能夠工作,對我而言并沒有什么幫助,因為它并沒有向我保證你的程序從我現在集成之后的每一分鐘都將會工作,以及它并沒有向我保證你的程序將依然能夠工作五年,那時你已經離開了很長的時間。
于是,測試的第二個目標就是生成可持續保持其價值的測試。除原作者以外的其他人必須能夠執行測試并解釋其結果。應該能夠將不同作者的測試結合起來并在一起運行,而不必擔心相互沖突。
最后,必須能夠以現有的測試作為支點來生成新的測試。生成一個裝置(setup)或夾具(fixture)是昂貴的,并且一個框架必須能夠對夾具進行重用,以運行不同的測試。哦,還有別的嗎?
3.JUnit的設計
JUnit的設計將以一種首次在Patterns Generate Architectures(請參見"Patterns Generate Architectures", Kent Beck and Ralph Johnson, ECOOP 94)中使用的風格來呈現。其思想是通過從零開始來應用模式,然后一個接一個,直至你獲得系統架構的方式來講解一個系統的設計。我們將提出需要解決的架構問題,總結用來解決問題的模式,然后展示如何將模式應用于JUnit。
3.1 由此開始-TestCase
首先我們必須構建一個對象來表達我們的基本概念,TestCase(測試案例)。開發者經常在頭腦中存在著測試案例,但在實現它們的時候卻采用了許多不同的方式-
· 打印語句
· 調試器表達式
· 測試腳本
如果我們想要輕松地操縱測試,就必須將它們構建成對象。這將會獲取到一個僅僅是隱藏在開發者頭腦中的測試,并使之具體化,其支持我們創建測試的目標,即能夠持續地保持它們的價值。同時,對象的開發者比較習慣于使用對象來進行開發,因此將測試構建成對象的決定支持我們的目標-使測試的編寫更加吸引人(或至少是不太華麗)。
Command(命令)模式(請參見Gamma, E., et al. Design Patterns: Elements of Reusable Object-Oriented Software, Addison-Wesley, Reading, MA, 1995)則能夠比較好地滿足我們的需求。摘引其意圖(intent),“將一個請求封裝成一個對象,從而使你可用不同的請求對客戶進行參數化;對請求進行排隊或記錄請求日志...”Command告訴我們可以為一個操作生成一個對象并給出它的一個“execute(執行)”方法。以下代碼定義了TestCase類:
因為我們期望可以通過繼承來對該類進行重用,我們將其聲明為“public abstract”。暫時忽略其實現接口Test的事實。鑒于當前設計的需要,你可以將TestCase看作是一個孤立的類。
每一個TestCase在創建時都要有一個名稱,因此若一個測試失敗了,你便可識別出失敗的是哪個測試。
為了闡述JUnit的演變過程,我們將使用圖(diagram)來展示構架的快照(snapshot)。我們使用的標記很簡單。其使用包含相關模式的尖方框來標注類。當類在模式中的角色(role)顯而易見時,則僅顯示模式的名稱。如果角色并不清晰,則在尖方框中增加與該類相關的參與者的名稱。該標記可使圖的混亂度降到最小限度,并首次見諸于Applying Design Patterns in Java(請參見Gamma, E., Applying Design Patterns in Java, in Java Gems, SIGS Reference Library, 1997)。圖1展示了這種應用于TestCase中的標記。由于我們是在處理一個單獨的類并且沒有不明確的地方,因此僅顯示模式的名稱。

圖1 TestCase應用Command
3.2 空白填充-run()
接下來要解決的問題是給開發者一個便捷的“地方”,用于放置他們的夾具代碼和測試代碼。將TestCase聲明為abstract是指開發者希望通過子類化(subclassing)來對TestCase進行重用。然而,如果我們所有能作的就是提供一個只有一個變量且沒有行為的超類,那么將無法做太多的工作來滿足我們的首個目標-使測試更易于編寫。
幸運的是,所有測試都具有一個共同的結構-建立一個測試夾具,在夾具上運行一些代碼,檢查結果,然后清理夾具。這意味著每一個測試將與一個新的夾具一起運行,并且一個測試的結果不會影響到其它測試的結果。這支持測試價值最大化的目標。
Template Method(模板方法)比較好地涉及到我們的問題。摘引其意圖,“定義一個操作中算法的骨架,并將一些步驟延遲到子類中。Template Method使得子類能夠不改變一個算法的結構便可重新定義該算法的某些特定步驟。”這完全恰當。我們就是想讓開發者能夠分別來考慮如何編寫夾具(建立和拆卸)代碼,以及如何編寫測試代碼。不管怎樣,這種執行的次序對于所有測試都將保持相同,而不管夾具代碼如何編寫,或測試代碼如何編寫。
Template Method如下:
這些方法被缺省實現為“什么都不做”:
由于setUp和tearDown會被用來重寫(override),而且其將由框架來進行調用,因此我們將其聲明為protected。我們的第二個快照如圖2所示。

圖2 TestCase.run()應用Template Method
3.3 結果報告-TestResult
如果一個TestCase在森林中運行,是否有人關心其結果呢?當然-你之所以運行測試就是為了要證實它們能夠運行。測試運行完后,你想要一個記錄,一個什么能夠工作和什么未能工作的總結。
如果測試具有相等的成功或失敗的機會,或者如果我們剛剛運行一個測試,我們可能只是在TestCase對象中設定一個標志,并且當測試完畢時去看這個標志。然而,測試(往往)是非常不均勻的-他們通常都會工作。因此我們只是想要記錄失敗,以及對成功的一個高度濃縮的總結。
The Smalltalk Best Practice Patterns(請參見 Beck, K. Smalltalk Best Practice Patterns, Prentice Hall, 1996)有一個可以適用的模式,稱為Collecting Parameter(收集參數)。其建議當你需要在多個方法間進行結果收集時,應該在方法中增加一個參數,并傳遞一個對象來為你收集結果。我們創建一個新的對象,TestResult(測試結果),來收集運行的測試的結果。
這個簡單版本的TestResult僅僅能夠計算所運行測試的數目。為了使用它,我們不得不在TestCase.run()方法中添加一個參數,并通知TestResult該測試正在運行:
并且TestResult必須要記住所運行測試的數目:
我們將TestResult的stratTest方法聲明為synchronized,從而當測試運行在不同的線程中時,一個單獨的TestResult能夠安全地對結果進行收集。最后,我們想要保持TestCase簡單的外部接口,因此創建一個無參的run()版本,其負責創建自己的TestResult。
我們下面的設計快照可如圖3所示。

圖3 TestResult應用Collecting Parameter
如果測試總是能夠正確運行,那么我們將沒有必要編寫它們。只有當測試失敗時測試才是讓人感興趣的,尤其是當我們沒有預期到它們會失敗的時候。更有甚者,測試能夠以我們所預期的方式失敗,例如通過計算一個不正確的結果;或者它們能夠以更加吸引人的方式失敗,例如通過編寫一個數組越界。無論測試怎樣失敗,我們都想執行后面的測試。
JUnit區分了失敗(failures)和錯誤(errors)。失敗的可能性是可預期的,并且以使用斷言(assertion)來進行檢查。而錯誤則是不可預期的問題,如ArrayIndexOutOfBoundsException。失敗可通過一個AssertionFailedError來發送。為了能夠識別出一個不可預期的錯誤和一個失敗,將在catch子句(1)中對失敗進行捕獲。子句(2)則捕獲所有其它的異常,并確保我們的測試能夠繼續運行...
TestCase提供的assert方法會觸發一個AssertionFailedError。JUnit針對不同的目的提供一組assert方法。下面只是最簡單的一個:
AssertionFailedError不應該由客戶(TestCase中的一個測試方法)來負責捕獲,而應該由Template Method內部的TestCase.run()來負責。因此我們將AssertionFailedError派生自Error。
在TestResult中收集錯誤的方法可如下所示:
TestFailure是一個小的框架內部幫助類(helper class),其將失敗的測試和為后續報告發送信號的異常綁定在一起。
規范形式的Collecting parameter模式要求我們將Collecting parameter傳遞給每一個方法。如果我們遵循該建議,每一個測試方法都將需要TestResult的參數。其將會造成這些方法簽名(signature)的“污染”。使用異常來發送失敗可以作為一個友善的副作用,使我們能夠避免這種簽名的污染。一個測試案例方法,或一個其所調用的幫助方法(helper method),可在不必知道TestResult的情況下拋出一個異常。作為一個進修材料,這里給出一個簡單的測試方法,其來自于我們MoneyTest套件(【譯者注】請參見JUnit發布版本中附帶的另外一篇文章JUnit Test Infected: Programmers Love Writing Tests)。其演示了一個測試方法是如何不必知道任何關于TestResult的信息的。
JUnit提出了關于TestResult的不同實現。其缺省實現是對失敗和錯誤的數目進行計數并收集結果。TextTestResult收集結果并以一種文本的形式來表達它們。最后,JUnit Test Runner的圖形版本則使用UITestResult來更新圖形化的測試狀態。
TestResult是框架的一個擴展點(extension point)。客戶能夠自定義它們的TestResult類,例如HTMLTestResult可將結果上報為一個HTML文檔。
3.4 不愚蠢的子類-再論TestCase
我們已經應用Command來表現一個測試。Command依賴于一個單獨的像execute()這樣的方法(在TestCase中稱為run())來對其進行調用。這個簡單接口允許我們能夠通過相同的接口來調用一個command的不同實現。
我們需要一個接口對我們的測試進行一般性地運行。然而,所有的測試案例都被實現為相同類的不同方法。這避免了不必要的類擴散(proliferation of classes)。一個給定的測試案例類(test case class)可以實現許多不同的方法,每一個方法定義了一個單獨的測試案例(test case)。每一個測試案例都有一個描述性的名稱,如testMoneyEquals或testMoneyAdd。測試案例并不符合簡單的command接口。相同Command類的不同實例需要與不同的方法來被調用。因此我們下面的問題就是,使所有測試案例從測試調用者的角度上看都是相同的。
回顧當前可用的設計模式所涉及的問題,Adapter(適配器)模式便映入腦海。Adapter具有以下意圖“將一個類的接口轉換成客戶希望的另外一個接口”。這聽起來非常適合。Adapter告訴我們不同的這樣去做的方式。其中之一便是class adapter(類適配器),其使用子類化來對接口進行適配。例如,為了將testMoneyEquals適配為runTest,我們實現了一個MoneyTest的子類并重寫runTest方法來調用testMoneyEquals。
使用子類化需要我們為每一個測試案例都實現一個子類。這便給測試者放置了一個額外的負擔。這有悖于JUnit的目標,即框架應該盡可能地使測試案例的增加變得簡單。此外,為每一個測試方法創建一個子類會造成類膨脹(class bloat)。許多類將僅具有一個單獨的方法,這種開銷不值得,而且很難會提出有意義的名稱。
Java提供了匿名內部類(anonymous inner class),其提供了一個讓人感興趣的Java所專門的方案來解決類的命名問題。通過匿名內部類我們能夠創建一個Adapter而不必創造一個類的名稱:
這與完全子類化相比要便捷許多。其是以開發者的一些負擔作為代價以保持編譯時期的類型檢查(compile-time type checking)。Smalltalk Best Practice Pattern描述了另外的方案來解決不同實例的問題,這些實例是在共同的pluggable behavior(插件式行為)標題下的不同表現。該思想是使用一個單獨的參數化的類來執行不同的邏輯,而無需進行子類化。
Pluggable behavior的最簡單形式是Pluggable Selector(插件式選擇器)。Pluggable Selector在一個實例變量中保存了一個Smalltalk的selector方法。該思想并不局限于Smalltalk,其也適用于Java。在Java中并沒有一個selector方法的標記。但是Java reflection(反射) API允許我們可以根據一個方法名稱的表示字符串來調用該方法。我們可以使用該種特性來實現一個Java版的pluggable selector。岔開話題而言,我們通常不會在平常的應用程序中使用反射。在我們的案例中,我們正在處理的是一個基礎設施框架,因此它可以戴上反射的帽子。
JUnit可以讓客戶自行選擇,是使用pluggable selector,或是實現上面所提到的匿名adapter類。正因如此,我們提供pluggable selector作為runTest方法的缺省實現。在該情況下,測試案例的名稱必須要與一個測試方法的名稱相一致。如下所示,我們使用反射來對方法進行調用。首先我們會查找Method對象。一旦我們有了method對象,便會調用它并傳遞其參數。由于我們的測試方法沒有參數,所以我們可以傳遞一個空的參數數組。
JDK1.1的reflection API僅允許我們發現public的方法。基于這個原因,你必須將測試方法聲明為public,否則將會得到一個NoSuchMethodException異常。
在下面的設計快照中,添加進了Adapter和Pluggable Selector。

圖4 TestCase應用Adapter(與一個匿名內部類一起)或Pluggable Selector
3.5 不必關心一個或多個-TestSuit
為了獲得對系統狀態的信心,我們需要運行許多測試。到現在為止,JUnit能夠運行一個單獨的測試案例并在一個TestResult中報告結果。我們接下來的挑戰是要對其進行擴展,以使其能夠運行許多不同的測試。當測試調用者不必關心其運行的是一個或多個測試案例時,這個問題便能夠輕松地解決。能夠在該情況下度過難關的一個流行模式就是Composite(組合)。摘引其意圖,“將對象組合成樹形結構以表示‘部分-整體’的層次結構。Composite使得用戶對單個對象和組合對象的使用具有一致性。”在這里‘部分-整體’的層次結構是讓人感興趣的地方。我們想支持能夠層層相套的測試套件。
Composite引入如下的參與者:
· Component:聲明我們想要使用的接口,來與我們的測試進行交互。
· Composite:實現該接口并維護一個測試的集合。
· Leaf:代表composite中的一個測試案例,其符合Component接口。
該模式告訴我們要引入一個抽象類,來為單獨的對象和composite對象定義公共的接口。這個類的基本意圖就是定義一個接口。在Java中應用Composite時,我們更傾向于定義一個接口,而非抽象類。使用接口避免了將JUnit提交成一個具體的基類來用于測試。所必需的是這些測試要符合這個接口。因此我們對模式的描述進行變通,并引入一個Test接口:
TestCase對應著Composite中的一個Leaf,并且實現了我們上面所看到的這個接口。
下面,我們引入參與者Composite。我們將其取名為TestSuit(測試套件)類。TestSuit在一個Vector中保存了其子測試(child test):
run()方法對其子成員進行委托(delegate):

圖5 TestSuit應用Composite
最后,客戶必須能將測試添加到一個套件中,它們將使用addTest方法來這樣做:
注意所有上面的代碼是如何僅對Test接口進行依賴的。由于TestCase和TestSuit兩者都符合Test接口,我們可以遞歸地將測試套件再組合成套件。所有開發者都能夠創建他們自己的TestSuit。我們可創建一個組合了這些套件的TestSuit來運行它們所有的。
下面是一個創建TestSuit的示例:
這會很好地工作,但它需要我們手動地將所有測試添加到一個套件中。早期的JUnit采用者告訴我們這樣是愚蠢的。只要你編寫一個新的測試案例,你就必須記著要將其添加到一個static的suit()方法中,否則其將不會運行。我們添加了一個TestSuit的便捷構造方法,該構造方法將測試案例類作為一個參數。其意圖是提取(extract)測試方法,并創建一個包含這些測試方法的套件。測試方法必須遵循的簡單的約定是,以前綴“test”開頭且不帶參數。便捷構造方法就使用該約定,通過使用反射發現測試方法來構造測試對象。使用該構造方法,以上代碼將會簡化為:
當你只是想運行測試案例的一個子集時,則最初的方式將依然有用。
3.6 總結
現在我們位于JUnit走馬觀花的最后。通過模式的角度來闡述JUnit的設計,可如下圖所示。

圖6 JUnit模式總結
注意TestCase作為框架抽象的中心,其是如何與四個模式進行相關的。成熟的對象設計的描述展示了這種相同的“模式密度”。設計的中心是一個豐富的關系集合,這些關系與所支持的參與者(player)相互關聯。
這是另外一種看待JUnit中所有模式的方式。在這個情節圖板(storyboard)上,依次對每個模式的影響進行抽象地表示。于是,Command模式創建了TestCase類,Template Method模式創建了run方法,等等。(情節圖板的標記是在圖6中標記的基礎上刪除了所有的文字)。

圖7 JUnit模式的情節圖板
關于情節圖板有一點要注意的是,圖的復雜性是如何在我們應用Composite時進行躍遷的。其以圖示的方式證實了我們的直覺,即Composite是一個強大的模式,但它會“使得圖變得復雜。”因此應該謹慎地予以使用。
4 結論
最后,讓我們作一些全面的觀察:
· 模式
我們發現從模式的角度來論述設計是非常寶貴的,無論是在我們進行框架的開發中,還是我們試圖向其他人論述它時。你現在正處于一個完美的位置來判定,以模式來描述一個框架是否有效。如果你喜歡上面的論述,請為你自己的系統嘗試相同的表現風格。
· 模式密度
TestCase周圍的模式“密度”比較高,其是JUnit的關鍵抽象。高模式密度的設計更加易于使用,但卻更加難于修改。我們發現像這樣一個在關鍵抽象周圍的高模式密度,對于成熟的框架而言是常見的。其對立面則應適用于那些不成熟的框架-它們應該具有低模式密度。一旦你發現你所要真正解決的問題,你便能夠開始“濃縮(compress)”這個解決方案,直到一個模式越來越密集的區域,而這些模式在其中提供了杠桿的作用。
· 用自己做的東西
一旦我們完成了基本的單元測試功能,我們自身就要將其應用起來。TestCase可以驗證框架能夠為錯誤,成功和失敗報告正確的結果。我們發現隨著框架設計的繼續演變,這是無價的。我們發現JUnit的最具挑戰性的應用便是測試其本身的行為。
· 交集(intersection),而非并集(union)
在框架開發中有一個誘惑就是,包含每一個你所能夠具有的特性。畢竟,你想使框架盡可能得有價值。然而,會有一種阻礙-開發者不得不來決定使用你的框架。框架所具有的特性越少,那么學起來就越容易,開發者使用它的可能性就越大。JUnit便是根據這種風格寫就的。其僅實現了那些測試運行所完全基本的特性-運行測試的套件,使各個測試的執行彼此相互隔離,以及測試的自動運行。是的,我們無法抵抗對于一些特性的添加,但是我們會小心地將其放到它們自己的擴展包中(test.extensions)。該包中有一個值得注意的成員是TestDecorator,其允許在一個測試之前和之后可以執行附加的代碼。
· 框架編寫者要讀他們的代碼
我們花在閱讀JUnit的代碼上的時間比起編寫它的時間要多出很多。而且花在去除重復功能上的時間幾乎與添加新功能的時間相等。我們積極地進行設計上的實驗,以多種我們能夠想出的不同方式來添加新的類以及移動職責。通過對JUnit持續不斷地洞察(測試,對象設計,框架開發),以及發表更深入的文章的機會,我們因為我們的偏執而獲得了回報(并將依然獲得回報)。
Junit的最新版本可從ftp://www.armaties.com/D/home/armaties/ftp/TestingFramework/JUnit/下載。
在一篇早些的文章(請參見Test Infected: Programmers Love Writing Tests, Java Report, July 1998, Volume 3, Number 7)中,我們描述了如何使用一個簡單的框架來編寫可重復的測試。在本文中我們將匆匆一瞥其內中細節,并向你展示該框架本身是如何被構造的。
我們細致地研究JUint框架并思索如何來構造它。我們發現了許多不同層次上的教訓。在本文中,我們將嘗試著立刻與它們進行溝通,這是一個令人絕望的任務,但至少它是在我們向你展示設計和構造一件價值被證實的軟件的上下文中來進行的。
我們引發了一個關于框架目標的討論。在對框架本身的表達期間,目標將重復出現許多小的細節中。此后,我們提出框架的設計和實現。設計將從模式(驚奇,驚奇)的角度進行描述,并作為優美的程序來予以實現。我們總結了一些優秀的關于框架開發的想法。
2.什么是JUnit的目標呢?
首先,我們不得不回到開發的假定上去。如果缺少一個程序特性的自動測試(automated test),我們便假定其無法工作。這看起來要比主流的假定更加安全,主流的假定認為如果開發者向我們保證一個程序特性能夠工作,那么現在和將來其都會永遠工作。
從這個觀點來看,當開發者編寫和調試代碼時,它們的工作并沒有完成,它們還要必須編寫測試來演示程序能夠工作。然而,每個人都太忙,他們要做的事情太多,他們沒有充足的時間用于測試。我已經有太多的代碼需要編寫,要我如何再來編寫測試代碼?回答我,強硬的項目經理先生。因此,首要目標就是編寫一個框架,在這個框架中開發者能夠看到實際來編寫測試的希望之光。該框架必須要使用常見的工具,從而學習起來不會有太多的新東西。其不能比完全編寫一個新測試所必須的工作更多。必須排除重復性的工作。
如果所有測試都這樣去做的話,你將可以僅在一個調試器中編寫表達式來完成。然而,這對于測試而言尚不充分。告訴我你的程序現在能夠工作,對我而言并沒有什么幫助,因為它并沒有向我保證你的程序從我現在集成之后的每一分鐘都將會工作,以及它并沒有向我保證你的程序將依然能夠工作五年,那時你已經離開了很長的時間。
于是,測試的第二個目標就是生成可持續保持其價值的測試。除原作者以外的其他人必須能夠執行測試并解釋其結果。應該能夠將不同作者的測試結合起來并在一起運行,而不必擔心相互沖突。
最后,必須能夠以現有的測試作為支點來生成新的測試。生成一個裝置(setup)或夾具(fixture)是昂貴的,并且一個框架必須能夠對夾具進行重用,以運行不同的測試。哦,還有別的嗎?
3.JUnit的設計
JUnit的設計將以一種首次在Patterns Generate Architectures(請參見"Patterns Generate Architectures", Kent Beck and Ralph Johnson, ECOOP 94)中使用的風格來呈現。其思想是通過從零開始來應用模式,然后一個接一個,直至你獲得系統架構的方式來講解一個系統的設計。我們將提出需要解決的架構問題,總結用來解決問題的模式,然后展示如何將模式應用于JUnit。
3.1 由此開始-TestCase
首先我們必須構建一個對象來表達我們的基本概念,TestCase(測試案例)。開發者經常在頭腦中存在著測試案例,但在實現它們的時候卻采用了許多不同的方式-
· 打印語句
· 調試器表達式
· 測試腳本
如果我們想要輕松地操縱測試,就必須將它們構建成對象。這將會獲取到一個僅僅是隱藏在開發者頭腦中的測試,并使之具體化,其支持我們創建測試的目標,即能夠持續地保持它們的價值。同時,對象的開發者比較習慣于使用對象來進行開發,因此將測試構建成對象的決定支持我們的目標-使測試的編寫更加吸引人(或至少是不太華麗)。
Command(命令)模式(請參見Gamma, E., et al. Design Patterns: Elements of Reusable Object-Oriented Software, Addison-Wesley, Reading, MA, 1995)則能夠比較好地滿足我們的需求。摘引其意圖(intent),“將一個請求封裝成一個對象,從而使你可用不同的請求對客戶進行參數化;對請求進行排隊或記錄請求日志...”Command告訴我們可以為一個操作生成一個對象并給出它的一個“execute(執行)”方法。以下代碼定義了TestCase類:
| public abstract class TestCase implements Test { … } |
因為我們期望可以通過繼承來對該類進行重用,我們將其聲明為“public abstract”。暫時忽略其實現接口Test的事實。鑒于當前設計的需要,你可以將TestCase看作是一個孤立的類。
每一個TestCase在創建時都要有一個名稱,因此若一個測試失敗了,你便可識別出失敗的是哪個測試。
| public abstract class TestCase implements Test { private final String fName; public TestCase(String name) { fName= name; } public abstract void run(); … } |
為了闡述JUnit的演變過程,我們將使用圖(diagram)來展示構架的快照(snapshot)。我們使用的標記很簡單。其使用包含相關模式的尖方框來標注類。當類在模式中的角色(role)顯而易見時,則僅顯示模式的名稱。如果角色并不清晰,則在尖方框中增加與該類相關的參與者的名稱。該標記可使圖的混亂度降到最小限度,并首次見諸于Applying Design Patterns in Java(請參見Gamma, E., Applying Design Patterns in Java, in Java Gems, SIGS Reference Library, 1997)。圖1展示了這種應用于TestCase中的標記。由于我們是在處理一個單獨的類并且沒有不明確的地方,因此僅顯示模式的名稱。

圖1 TestCase應用Command
3.2 空白填充-run()
接下來要解決的問題是給開發者一個便捷的“地方”,用于放置他們的夾具代碼和測試代碼。將TestCase聲明為abstract是指開發者希望通過子類化(subclassing)來對TestCase進行重用。然而,如果我們所有能作的就是提供一個只有一個變量且沒有行為的超類,那么將無法做太多的工作來滿足我們的首個目標-使測試更易于編寫。
幸運的是,所有測試都具有一個共同的結構-建立一個測試夾具,在夾具上運行一些代碼,檢查結果,然后清理夾具。這意味著每一個測試將與一個新的夾具一起運行,并且一個測試的結果不會影響到其它測試的結果。這支持測試價值最大化的目標。
Template Method(模板方法)比較好地涉及到我們的問題。摘引其意圖,“定義一個操作中算法的骨架,并將一些步驟延遲到子類中。Template Method使得子類能夠不改變一個算法的結構便可重新定義該算法的某些特定步驟。”這完全恰當。我們就是想讓開發者能夠分別來考慮如何編寫夾具(建立和拆卸)代碼,以及如何編寫測試代碼。不管怎樣,這種執行的次序對于所有測試都將保持相同,而不管夾具代碼如何編寫,或測試代碼如何編寫。
Template Method如下:
| public void run() { setUp(); runTest(); tearDown(); } |
這些方法被缺省實現為“什么都不做”:
| protected void runTest() { } protected void setUp() { } protected void tearDown() { } |
由于setUp和tearDown會被用來重寫(override),而且其將由框架來進行調用,因此我們將其聲明為protected。我們的第二個快照如圖2所示。

圖2 TestCase.run()應用Template Method
3.3 結果報告-TestResult
如果一個TestCase在森林中運行,是否有人關心其結果呢?當然-你之所以運行測試就是為了要證實它們能夠運行。測試運行完后,你想要一個記錄,一個什么能夠工作和什么未能工作的總結。
如果測試具有相等的成功或失敗的機會,或者如果我們剛剛運行一個測試,我們可能只是在TestCase對象中設定一個標志,并且當測試完畢時去看這個標志。然而,測試(往往)是非常不均勻的-他們通常都會工作。因此我們只是想要記錄失敗,以及對成功的一個高度濃縮的總結。
The Smalltalk Best Practice Patterns(請參見 Beck, K. Smalltalk Best Practice Patterns, Prentice Hall, 1996)有一個可以適用的模式,稱為Collecting Parameter(收集參數)。其建議當你需要在多個方法間進行結果收集時,應該在方法中增加一個參數,并傳遞一個對象來為你收集結果。我們創建一個新的對象,TestResult(測試結果),來收集運行的測試的結果。
| public class TestResult extends Object { protected int fRunTests; public TestResult() { fRunTests= 0; } } |
這個簡單版本的TestResult僅僅能夠計算所運行測試的數目。為了使用它,我們不得不在TestCase.run()方法中添加一個參數,并通知TestResult該測試正在運行:
| public void run(TestResult result) { result.startTest(this); setUp(); runTest(); tearDown(); } |
并且TestResult必須要記住所運行測試的數目:
| public synchronized void startTest(Test test) { fRunTests++; } |
我們將TestResult的stratTest方法聲明為synchronized,從而當測試運行在不同的線程中時,一個單獨的TestResult能夠安全地對結果進行收集。最后,我們想要保持TestCase簡單的外部接口,因此創建一個無參的run()版本,其負責創建自己的TestResult。
| public TestResult run() { TestResult result= createResult(); run(result); return result; } protected TestResult createResult() { return new TestResult(); } |
我們下面的設計快照可如圖3所示。

圖3 TestResult應用Collecting Parameter
如果測試總是能夠正確運行,那么我們將沒有必要編寫它們。只有當測試失敗時測試才是讓人感興趣的,尤其是當我們沒有預期到它們會失敗的時候。更有甚者,測試能夠以我們所預期的方式失敗,例如通過計算一個不正確的結果;或者它們能夠以更加吸引人的方式失敗,例如通過編寫一個數組越界。無論測試怎樣失敗,我們都想執行后面的測試。
JUnit區分了失敗(failures)和錯誤(errors)。失敗的可能性是可預期的,并且以使用斷言(assertion)來進行檢查。而錯誤則是不可預期的問題,如ArrayIndexOutOfBoundsException。失敗可通過一個AssertionFailedError來發送。為了能夠識別出一個不可預期的錯誤和一個失敗,將在catch子句(1)中對失敗進行捕獲。子句(2)則捕獲所有其它的異常,并確保我們的測試能夠繼續運行...
| public void run(TestResult result) { result.startTest(this); setUp(); try { runTest(); } catch (AssertionFailedError e) { //1 result.addFailure(this, e); } catch (Throwable e) { // 2 result.addError(this, e); } finally { tearDown(); } } |
TestCase提供的assert方法會觸發一個AssertionFailedError。JUnit針對不同的目的提供一組assert方法。下面只是最簡單的一個:
| protected void assert(boolean condition) { if (!condition) throw new AssertionFailedError(); }(【譯者注】由于與JDK中的關鍵字assert沖突,在最新的JUnit發布版本中此處的assert已經改為assertTrue。) |
AssertionFailedError不應該由客戶(TestCase中的一個測試方法)來負責捕獲,而應該由Template Method內部的TestCase.run()來負責。因此我們將AssertionFailedError派生自Error。
| public class AssertionFailedError extends Error { public AssertionFailedError () {} } |
在TestResult中收集錯誤的方法可如下所示:
| public synchronized void addError(Test test, Throwable t) { fErrors.addElement(new TestFailure(test, t)); } public synchronized void addFailure(Test test, Throwable t) { fFailures.addElement(new TestFailure(test, t)); } |
TestFailure是一個小的框架內部幫助類(helper class),其將失敗的測試和為后續報告發送信號的異常綁定在一起。
| public class TestFailure extends Object { protected Test fFailedTest; protected Throwable fThrownException; } |
規范形式的Collecting parameter模式要求我們將Collecting parameter傳遞給每一個方法。如果我們遵循該建議,每一個測試方法都將需要TestResult的參數。其將會造成這些方法簽名(signature)的“污染”。使用異常來發送失敗可以作為一個友善的副作用,使我們能夠避免這種簽名的污染。一個測試案例方法,或一個其所調用的幫助方法(helper method),可在不必知道TestResult的情況下拋出一個異常。作為一個進修材料,這里給出一個簡單的測試方法,其來自于我們MoneyTest套件(【譯者注】請參見JUnit發布版本中附帶的另外一篇文章JUnit Test Infected: Programmers Love Writing Tests)。其演示了一個測試方法是如何不必知道任何關于TestResult的信息的。
| public void testMoneyEquals() { assert(!f12CHF.equals(null)); assertEquals(f12CHF, f12CHF); assertEquals(f12CHF, new Money(12, "CHF")); assert(!f12CHF.equals(f14CHF)); }(【譯者注】由于與JDK中的關鍵字assert沖突,在最新的JUnit發布版本中此處的assert已經改為assertTrue。) |
JUnit提出了關于TestResult的不同實現。其缺省實現是對失敗和錯誤的數目進行計數并收集結果。TextTestResult收集結果并以一種文本的形式來表達它們。最后,JUnit Test Runner的圖形版本則使用UITestResult來更新圖形化的測試狀態。
TestResult是框架的一個擴展點(extension point)。客戶能夠自定義它們的TestResult類,例如HTMLTestResult可將結果上報為一個HTML文檔。
3.4 不愚蠢的子類-再論TestCase
我們已經應用Command來表現一個測試。Command依賴于一個單獨的像execute()這樣的方法(在TestCase中稱為run())來對其進行調用。這個簡單接口允許我們能夠通過相同的接口來調用一個command的不同實現。
我們需要一個接口對我們的測試進行一般性地運行。然而,所有的測試案例都被實現為相同類的不同方法。這避免了不必要的類擴散(proliferation of classes)。一個給定的測試案例類(test case class)可以實現許多不同的方法,每一個方法定義了一個單獨的測試案例(test case)。每一個測試案例都有一個描述性的名稱,如testMoneyEquals或testMoneyAdd。測試案例并不符合簡單的command接口。相同Command類的不同實例需要與不同的方法來被調用。因此我們下面的問題就是,使所有測試案例從測試調用者的角度上看都是相同的。
回顧當前可用的設計模式所涉及的問題,Adapter(適配器)模式便映入腦海。Adapter具有以下意圖“將一個類的接口轉換成客戶希望的另外一個接口”。這聽起來非常適合。Adapter告訴我們不同的這樣去做的方式。其中之一便是class adapter(類適配器),其使用子類化來對接口進行適配。例如,為了將testMoneyEquals適配為runTest,我們實現了一個MoneyTest的子類并重寫runTest方法來調用testMoneyEquals。
| public class TestMoneyEquals extends MoneyTest { public TestMoneyEquals() { super("testMoneyEquals"); } protected void runTest () { testMoneyEquals(); } } |
使用子類化需要我們為每一個測試案例都實現一個子類。這便給測試者放置了一個額外的負擔。這有悖于JUnit的目標,即框架應該盡可能地使測試案例的增加變得簡單。此外,為每一個測試方法創建一個子類會造成類膨脹(class bloat)。許多類將僅具有一個單獨的方法,這種開銷不值得,而且很難會提出有意義的名稱。
Java提供了匿名內部類(anonymous inner class),其提供了一個讓人感興趣的Java所專門的方案來解決類的命名問題。通過匿名內部類我們能夠創建一個Adapter而不必創造一個類的名稱:
| TestCase test= new MoneyTest("testMoneyEquals ") { protected void runTest() { testMoneyEquals(); } }; |
這與完全子類化相比要便捷許多。其是以開發者的一些負擔作為代價以保持編譯時期的類型檢查(compile-time type checking)。Smalltalk Best Practice Pattern描述了另外的方案來解決不同實例的問題,這些實例是在共同的pluggable behavior(插件式行為)標題下的不同表現。該思想是使用一個單獨的參數化的類來執行不同的邏輯,而無需進行子類化。
Pluggable behavior的最簡單形式是Pluggable Selector(插件式選擇器)。Pluggable Selector在一個實例變量中保存了一個Smalltalk的selector方法。該思想并不局限于Smalltalk,其也適用于Java。在Java中并沒有一個selector方法的標記。但是Java reflection(反射) API允許我們可以根據一個方法名稱的表示字符串來調用該方法。我們可以使用該種特性來實現一個Java版的pluggable selector。岔開話題而言,我們通常不會在平常的應用程序中使用反射。在我們的案例中,我們正在處理的是一個基礎設施框架,因此它可以戴上反射的帽子。
JUnit可以讓客戶自行選擇,是使用pluggable selector,或是實現上面所提到的匿名adapter類。正因如此,我們提供pluggable selector作為runTest方法的缺省實現。在該情況下,測試案例的名稱必須要與一個測試方法的名稱相一致。如下所示,我們使用反射來對方法進行調用。首先我們會查找Method對象。一旦我們有了method對象,便會調用它并傳遞其參數。由于我們的測試方法沒有參數,所以我們可以傳遞一個空的參數數組。
| protected void runTest() throws Throwable { Method runMethod= null; try { runMethod= getClass().getMethod(fName, new Class[0]); } catch (NoSuchMethodException e) { assert("Method ""+fName+"" not found", false); } try { runMethod.invoke(this, new Class[0]); } // catch InvocationTargetException and IllegalAccessException } |
JDK1.1的reflection API僅允許我們發現public的方法。基于這個原因,你必須將測試方法聲明為public,否則將會得到一個NoSuchMethodException異常。
在下面的設計快照中,添加進了Adapter和Pluggable Selector。

圖4 TestCase應用Adapter(與一個匿名內部類一起)或Pluggable Selector
3.5 不必關心一個或多個-TestSuit
為了獲得對系統狀態的信心,我們需要運行許多測試。到現在為止,JUnit能夠運行一個單獨的測試案例并在一個TestResult中報告結果。我們接下來的挑戰是要對其進行擴展,以使其能夠運行許多不同的測試。當測試調用者不必關心其運行的是一個或多個測試案例時,這個問題便能夠輕松地解決。能夠在該情況下度過難關的一個流行模式就是Composite(組合)。摘引其意圖,“將對象組合成樹形結構以表示‘部分-整體’的層次結構。Composite使得用戶對單個對象和組合對象的使用具有一致性。”在這里‘部分-整體’的層次結構是讓人感興趣的地方。我們想支持能夠層層相套的測試套件。
Composite引入如下的參與者:
· Component:聲明我們想要使用的接口,來與我們的測試進行交互。
· Composite:實現該接口并維護一個測試的集合。
· Leaf:代表composite中的一個測試案例,其符合Component接口。
該模式告訴我們要引入一個抽象類,來為單獨的對象和composite對象定義公共的接口。這個類的基本意圖就是定義一個接口。在Java中應用Composite時,我們更傾向于定義一個接口,而非抽象類。使用接口避免了將JUnit提交成一個具體的基類來用于測試。所必需的是這些測試要符合這個接口。因此我們對模式的描述進行變通,并引入一個Test接口:
| public interface Test { public abstract void run(TestResult result); } |
TestCase對應著Composite中的一個Leaf,并且實現了我們上面所看到的這個接口。
下面,我們引入參與者Composite。我們將其取名為TestSuit(測試套件)類。TestSuit在一個Vector中保存了其子測試(child test):
| public class TestSuite implements Test { private Vector fTests= new Vector(); } |
run()方法對其子成員進行委托(delegate):
| public void run(TestResult result) { for (Enumeration e= fTests.elements(); e.hasMoreElements(); ) { Test test= (Test)e.nextElement(); test.run(result); } } |

圖5 TestSuit應用Composite
最后,客戶必須能將測試添加到一個套件中,它們將使用addTest方法來這樣做:
| public void addTest(Test test) { fTests.addElement(test); } |
注意所有上面的代碼是如何僅對Test接口進行依賴的。由于TestCase和TestSuit兩者都符合Test接口,我們可以遞歸地將測試套件再組合成套件。所有開發者都能夠創建他們自己的TestSuit。我們可創建一個組合了這些套件的TestSuit來運行它們所有的。
下面是一個創建TestSuit的示例:
| public static Test suite() { TestSuite suite= new TestSuite(); suite.addTest(new MoneyTest("testMoneyEquals")); suite.addTest(new MoneyTest("testSimpleAdd")); } |
這會很好地工作,但它需要我們手動地將所有測試添加到一個套件中。早期的JUnit采用者告訴我們這樣是愚蠢的。只要你編寫一個新的測試案例,你就必須記著要將其添加到一個static的suit()方法中,否則其將不會運行。我們添加了一個TestSuit的便捷構造方法,該構造方法將測試案例類作為一個參數。其意圖是提取(extract)測試方法,并創建一個包含這些測試方法的套件。測試方法必須遵循的簡單的約定是,以前綴“test”開頭且不帶參數。便捷構造方法就使用該約定,通過使用反射發現測試方法來構造測試對象。使用該構造方法,以上代碼將會簡化為:
| public static Test suite() { return new TestSuite(MoneyTest.class); } |
當你只是想運行測試案例的一個子集時,則最初的方式將依然有用。
3.6 總結
現在我們位于JUnit走馬觀花的最后。通過模式的角度來闡述JUnit的設計,可如下圖所示。

圖6 JUnit模式總結
注意TestCase作為框架抽象的中心,其是如何與四個模式進行相關的。成熟的對象設計的描述展示了這種相同的“模式密度”。設計的中心是一個豐富的關系集合,這些關系與所支持的參與者(player)相互關聯。
這是另外一種看待JUnit中所有模式的方式。在這個情節圖板(storyboard)上,依次對每個模式的影響進行抽象地表示。于是,Command模式創建了TestCase類,Template Method模式創建了run方法,等等。(情節圖板的標記是在圖6中標記的基礎上刪除了所有的文字)。

圖7 JUnit模式的情節圖板
關于情節圖板有一點要注意的是,圖的復雜性是如何在我們應用Composite時進行躍遷的。其以圖示的方式證實了我們的直覺,即Composite是一個強大的模式,但它會“使得圖變得復雜。”因此應該謹慎地予以使用。
4 結論
最后,讓我們作一些全面的觀察:
· 模式
我們發現從模式的角度來論述設計是非常寶貴的,無論是在我們進行框架的開發中,還是我們試圖向其他人論述它時。你現在正處于一個完美的位置來判定,以模式來描述一個框架是否有效。如果你喜歡上面的論述,請為你自己的系統嘗試相同的表現風格。
· 模式密度
TestCase周圍的模式“密度”比較高,其是JUnit的關鍵抽象。高模式密度的設計更加易于使用,但卻更加難于修改。我們發現像這樣一個在關鍵抽象周圍的高模式密度,對于成熟的框架而言是常見的。其對立面則應適用于那些不成熟的框架-它們應該具有低模式密度。一旦你發現你所要真正解決的問題,你便能夠開始“濃縮(compress)”這個解決方案,直到一個模式越來越密集的區域,而這些模式在其中提供了杠桿的作用。
· 用自己做的東西
一旦我們完成了基本的單元測試功能,我們自身就要將其應用起來。TestCase可以驗證框架能夠為錯誤,成功和失敗報告正確的結果。我們發現隨著框架設計的繼續演變,這是無價的。我們發現JUnit的最具挑戰性的應用便是測試其本身的行為。
· 交集(intersection),而非并集(union)
在框架開發中有一個誘惑就是,包含每一個你所能夠具有的特性。畢竟,你想使框架盡可能得有價值。然而,會有一種阻礙-開發者不得不來決定使用你的框架。框架所具有的特性越少,那么學起來就越容易,開發者使用它的可能性就越大。JUnit便是根據這種風格寫就的。其僅實現了那些測試運行所完全基本的特性-運行測試的套件,使各個測試的執行彼此相互隔離,以及測試的自動運行。是的,我們無法抵抗對于一些特性的添加,但是我們會小心地將其放到它們自己的擴展包中(test.extensions)。該包中有一個值得注意的成員是TestDecorator,其允許在一個測試之前和之后可以執行附加的代碼。
· 框架編寫者要讀他們的代碼
我們花在閱讀JUnit的代碼上的時間比起編寫它的時間要多出很多。而且花在去除重復功能上的時間幾乎與添加新功能的時間相等。我們積極地進行設計上的實驗,以多種我們能夠想出的不同方式來添加新的類以及移動職責。通過對JUnit持續不斷地洞察(測試,對象設計,框架開發),以及發表更深入的文章的機會,我們因為我們的偏執而獲得了回報(并將依然獲得回報)。
Junit的最新版本可從ftp://www.armaties.com/D/home/armaties/ftp/TestingFramework/JUnit/下載。