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 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/下載。