原文:JUnit A Cook's Tour 見 www.junit.org
1、介紹
2、目標
3、JUnit設計
3.1、從測試案例TestCase開始
3.2、在run()方法中填寫方法體
3.3、用TestResult對象報告結果
3.4、No stupid subclasses - TestCase again
3.5、不用擔心是一個測試案例還是許多測試案例-TestSuite
3.6、概要
4、結論
1、介紹
在較早的文章(Test Infected: Programmers Love Writing Tests)中,我們描述了如何用一個簡單
的架構編寫可重複的測試;本文則說明這個架構是如何構造的。
仔細地學習JUnit架構,從中可以看出我們是如何設計這個架構的。我們看到不同層次的JUnit教程,
但在本文中,我們希望更清楚地說明問題。弄清JUnit的設計思路是非常有價值的。
我們先討論一下Junit的目標,這些目標會在JUnit的每個細小之處得到體現。圍繞著JUnit的目標,我
們給出Junit架構的設計和實現。我們會用模式和程式實現例子來描述這個設計。我們還會看到,在開發這
個架構時,當然還有其它的可選途徑。
2、目標
什麼是JUnit的目標?
首先,我們回到開發的前提假設。我們假設如果一個程式不能自動化的測試,那麼它就不會工作。但有更
多的假設認為,如果開發人員保證程式能工作,那麼它就會永遠正常工作,與與這個假設相比,我們的假
設實在是太保守了。
從這個觀點出發,開發人員編寫了代碼,並進行了調試,還不能說他的工作完成了,他必須編寫測試
指令碼,證明程式工作正常。然而,每個人都很忙,沒有時間去進行測試工作。他們會說,我編寫程式碼
的時間都很緊,那有時間去寫測試代碼呢?
因此,首要的目標就是,構建一個測試架構,在這個架構裡,開發人員能編寫測試代碼。架構要使用
熟悉的工具,無需花很多精力就可以掌握。它還要消除不必要的代碼,除了必須的測試代碼外,消除重複
勞動。
如果僅僅這些是測試要作的,那麼你在調試器中寫一個運算式就可以實現。但是,測試不僅僅這些。
雖然你的程式工作很好,但這不夠,因為你不能保證整合後的即使一分鐘內你的程式是否還會正常,你更
不能保證5年內它還是否正常,那時你已經離開很久了。
因此,測試的第二個目標就是建立測試,並能保留這些測試,將來它們也是有價值的,其它的人可以
執行這些測試,並驗證測試結果。有可能的話,還要把不同人的測試收集在一起,一起執行,且不用擔心
它們之間互相干擾。
最後,還要能用已有的測試建立新的測試。每次建立新的測試設定或測試鉗(test fixture)是很花
費代價的,架構能複用測試設定,執行不同的測試。
3、JUnit設計
最早,JUnit的設計思路源於"用模式產生架構(Patterns Generate Architectures)"一文。它的思
想就是,從0開始設計一個系統,一個一個地應用模式,直到最後構造出這個系統的架構,這樣就完成一個
系統的設計。我們馬上提出要解決的架構問題,用模式來解決這個問題,並說明如何在JUnit中應用這些模
式的。
3.1、從測試案例TestCase開始
首先我們建立一個對象來表示基礎概念:測試案例(TestCase)。測試案例常常就存在於開發人員的
頭腦中,他們用不同的方式實現測試案例:
· 列印語句
· 調試運算式
· 測試指令碼
如何我們想很容易地操縱測試,那麼就必須把測試作為對象。開發人員腦海中的測試是模糊的,測試作
為對象,就使得測試更具體了,測試就可以長久保留以便將來有用,這是測試架構的目標之一。同時,對
象開發人員習慣於對象,因此把測試作為對象就能達到讓編寫測試代碼更具吸引力的目的。
在這裡,命令模式(command)滿足我們的需要。該模式把請求封裝成對象,即為請求操作產生一個對
象,這個對象中有一個“執行(execute)”方法。命令模式中,要求者不是直接調用命令執行者,而是通
過一個命令對象去調用執行者,具體說,先為命令請求產生一個命令對象,然後動態地在這個命令對象中
設定命令執行者,最後用命令對象的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的演化進程,我們用圖來表示各個設計階段的架構。我們用簡單的符號,灰色路標符號
表明所使用的模式。當這個類在模式中的角色很明顯時,就在路標中只指明模式名稱;如果這個類在模式
中的角色不清晰,則在路標中還註明該類對應的參與模式。這個路標符號避免了混亂,見圖1所示。
圖1 TestCase類應用了命令模式
3.2、在run()方法中填寫方法體
下面要解決的問題就是給出一個方便的地方,讓開發人員放置測試用的設定代碼和測試代碼。
TestCase定義為抽象的,表示開發人員要繼承TestCase來建立自己的測試案例。如果我們象剛才那樣,只
在TestCase中放置一個變數,沒有任何方法,那麼第一個目標,即易於編寫測試代這個目標就難以達到。
對於所有的測試,有一個通用的結構,在這個結構中,可以設定測試鉗夾(fixture),在測試鉗夾下
運行一些代碼,檢查運行結果,然後清除測試鉗夾。這表明,每個測試都運行在不同的鉗夾下,一個測試
的結果不會影響其它的測試結果,這點符合測試架構的價值最大化的目標。
模板方法(template method)模式很好地解決了上面提出的問題。模板方法模式的意圖就是,在父類
中定義一個演算法的操作的骨架,將具體的步驟延遲到子類中實現。模板方法在子類中重新定義一個演算法的
特定步驟,不用改變這個演算法的結構,這正好是我們的要求。我們只要求開發人員知道如何編寫fixture
(即setup和teardown)代碼,知道如何編寫測試代碼。fixtue代碼和測試代碼的執行順序對所有的測試都
是一樣的,不管fixture代碼和測試代碼是如何編寫的。
這就是我們需要的模板方法:
public void run() {
setUp();
runTest();
tearDown();
}
這個模板方法的預設實現就是什麼也不作。
protected void runTest() {
}
protected void setUp() {
}
protected void tearDown() {
}
既然setUp和tearDown方法要能被覆寫,同時還要能被架構調用,因此定義成保護的。這個階段的設計
2所示。
圖2 TestCase.run()方法應用了模板方法模式
3.3、用TestResult對象報告結果
如果一個TestCase在原始森林中運行,大概沒人關心它的測試結果。你運行測試是要得到一個測試記
錄,說明測試作了什麼,什麼沒有作。
如果一個測試成功和失敗的機會是相同的,或者我們只運行一個測試,那麼我們只用在測試中設定一
個標誌,當測試結束後檢查這個標誌即可。然而,測試成功和失敗機會是不均衡的,測試通常是成功的,
因此我們只注重於測試故障的記錄,對於成功的記錄我們只做一個總概。
在SmallTalk Best Practice Patterns中,有一個叫“收集參數(collecting parameter)”的模式,
當你需要在多個方法中收集結果時,你可以傳給方法一個參數或對象,用這個對象收集這些方法的執行結
果。我們建立一個新對象,測試結果(TestResult),去收集測試的結果。
public class TestResult extends Object {
protected int fRunTests;
public TestResult() {
fRunTests= 0;
}
}
這裡一個簡單的TestResult版本,它只是計數測試回合的數量。為了使用TestResult,我們必須把它
作為參數傳給TestCase.run()方法,並通知TestResult當前測試已經開始。
public void run(TestResult result) {
result.startTest(this); //通知TestResult測試開始
setUp();
runTest();
tearDown();
}
TestResult會跟蹤計數運行了多少個測試:
public synchronized void startTest(Test test) {
fRunTests++;
}
我們把TestREsult中的startTest方法定義成同步的,即安全執行緒的,那麼一個TestREsult對象就可以
收集不同線程中的測試的結果。我們想讓TestCase的介面保持簡單,因此我們建立了一個無參數版本的
run()方法,它建立自己的TestResult對象。
public TestResult run() {
TestResult result= createResult();
run(result);
return result;
}
protected TestResult createResult() {
return new TestResult();
}
這裡用到的設計3所示。
圖3:TestResult應用了收集參數模式
如果測試一直都是運行正確的,那麼我們就不用寫測試了。我們對測試的故障感興趣,特別是那些我
們未預料到的故障。當然,我們可以期望故障以我們所希望的方式出現,例如計算得出一個不正確的結
果,或者一個更奇特的故障方式,例如編寫一個數組越界錯誤。不管測試如何出現故障,我們還要能繼續
進行其後的測試。
JUnit在故障(failure)和錯誤(error)之間作了區分。故障是可預期的,用斷言來檢測,錯誤是
不可預期的,如數組越界例外(ArrayIndexOutOfBoundsException)。故障標識為AssertionFailedError
錯誤。為了從故障中區分不可預料的錯誤,故障用第一個catch語句捕獲,故障之外的錯誤用第二個catch
語句捕獲,這樣就保證了本測試之後的其它測試得以運行。
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();
}
}
AssertionFailedError故障是由TestCase提供的assert方法觸發的。JUnit為不同的用途提供了許多
assert方法,這裡有一個簡單的例子:
protected void assert(boolean condition) {
if (!condition)
throw new AssertionFailedError();
}
AssertionFailedError故障不是由測試客戶(測試的要求者,即TestCase中的測試方法)捕獲的,而
是在模板方法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是一個內部協助類,它將不成功的測試以及其運行中發生的例外對應起來,以
備將來報告。
public class TestFailure extends Object {
protected Test fFailedTest;
protected Throwable fThrownException;
}
收集參數要求把它傳遞給每一個方法。如果我們這樣作,每個測試方法需要有一個TestResult作為參
數,這會導致測試方法的簽名型構受到破壞;利用例外,我們可以避免簽名型構受到破壞,這也是對例外
的副作用的一個利用吧。測試案例方法,或者測試案例調用的協助方法拋出例外來,它不用知道
TestResult的資訊。MoneyTestSuite中的測試方法就可以作為例子,它表明測試方法不用知道TestResult
的任何資訊。
public void testMoneyEquals() {
assert(!f12CHF.equals(null));
assertEquals(f12CHF, f12CHF);
assertEquals(f12CHF, new Money(12, "CHF"));
assert(!f12CHF.equals(f14CHF));
}
JUnit中有很多不同用途的TestResult實現,預設的實現很簡單,它計數發生故障和錯誤的數量,並收
集結果。TextTestResult用文本的表現方式表示收集到的結果,而JUnit測試回合器利用UITestResult,用
圖形介面的方式表示收集的結果。
TestResult是JUnit架構的擴充點。客戶可以定義它們自己的TestResult類,比如,定義一個
HTMLTestResult類,用HTML文檔的形式報告測試結果。
3.4、No stupid subclasses - TestCase again
我們應用程式命令模式來表示一個測試。命令執行依賴一個這樣的方法:execute(),在TestCase稱為
run(),通過它使命令得到調用,這使得我們能用這個相同的介面實現不同的命令。
我們需要一個普遍的介面來運行我們的測試。然而所有的測試案例可能是在一個類中用不同的方法實
現的,這樣可以避免為每一種測試方法建立一個類,從而導致類的數量急劇增長。某個複雜測試案例類也
許實現許多不同的測試方法,每個測試方法定義了一個簡單測試案例。每個簡單測試案例方法有象這樣的
名字:testMoneyequals或testMoneyAdd,測試案例並不需要遵守那個簡單的命令模式介面,同一個
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架構的一個目
標就是,在增加一個用例時盡量保持簡單。另外,為每個測試方法建立一個子類也會導致類膨脹,如果有
許多類,這些類中就那麼一個方法,這是不值得的,為它們取有意義的名字都很困難。
Java提供了匿名內隱類機制,解決了命名問題。我們用匿名內隱類來達到Adapter目的,且不用命名:
TestCase test= new MoneyTest("testMoneyEquals ") {
protected void runTest() { testMoneyEquals(); }
};
這比通常的子類繼承方便多了,它仍然在編譯時間進行類型檢查,代價是增加了開發人員的負擔。
Smalltalk Best Practice Patterns描述了這個問題的另外一個解決方案,不同的執行個體在相同的
pluggable behavior下行為表現不同。其思想就是,使用一個類,這個類可以參數化,即根據不同的參數
值執行不同的邏輯,因此避免了子類繼承。
最簡單的可插入行為(pluggable behavior)形式是可插入選擇子(Pluggable Selector)。在
SmallTalk中,Pluggable Selector是一個變數,它指向一個方法,是一個方法指標。這個思想不局限於
SmallTalk,也適用於Java。在Java中沒有方法選擇子的概念,然而,Java的反射(reflection)API能根
據方法名這個字串來調用方法,我們能利用Java的反射特性實現Pluggable Selector。通常我們很少使
用Java反射,在這裡,我們要涉及一個底層結構架構,它實現了反射。
JUnit提供給測試客戶兩種選擇:或者使用Pluggable Selector,或者使用匿名內隱類。預設地,我們
使用Pluggable Selector方式,即runTest方法。在這種方式中,測試案例的名字必須與測試方法的名字一
致。如下所示,我們用反射特性調用方法。首先,我們查看方法對象,一旦有了這個方法對象,我們就可
以傳給它參數,並調用它。由於我們的測試方法不帶參數,因此,我們傳進一個空的參數數組:
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反射API只讓我們尋找public方法,因此你必須把測試方法定義為public,否則你會得到
NoSuchMethodException例外。
這是該階段的設計,Adapter模式和Pluggable Selector模式。
圖4:TestCase應用了Adapter模式(匿名內隱類)和Pluggable Selector模式
〔begin 譯者添加〕
由於TestCase中只有一個runTest方法,那麼是不是說一個TestCase中只能放一個測試方法呢?為此引入
Pluggable Selector模式。在TestCase中放置多個名為testXxx()的方法,在new一個TestCase時,用selector指
定哪個testXxx方法與模板方法runTest對接。
〔end 譯者添加〕
3.5、不用擔心是一個測試案例還是許多測試案例-TestSuite
一個系統通常要運行許多測試。現在,JUnit能運行一個測試,並用TestResult報告結果,下一步就是
擴充JUnit,讓它能運行許多不同的測試。如果測試的調用者並不在意它是運行一個測試還是許多測試,即
它用同樣的方式運行一個測試和運行許多測試,那麼這個問題就解決了。Composite模式可以解決這個問
題,它的意圖就是,將許多個物件組成樹狀的具有部分/整體層次的結構,Composite讓客戶用同樣的介面處
理單個的對象和整體組合對象。部分/整體的階層在此很有意義,一個組合測試可能是有許多小的組合
測試構成的,小的組合測試可能是有單個的簡單測試構成的。
Composite模式有以下參與者:
· Component:是一個公用的統一的介面,用於與測試互動,無論這個測試是簡單測試還是組合測試。
· Composite:用於維護測試集合的介面,這個測試集合就是組合測試。
· Leaf:表示簡單測試案例,遵從Component介面。
這個模式要求我們引入一個抽象類別,該類為簡單對象和組合對象定義了統一的介面,它的主要作用是
定義這個介面,在Java裡,我們直接使用介面,沒有必要用抽象類別來定義介面,因為Java有介面的概念,
而象C++沒有介面的概念,使用介面避免了將JUnit功能交付給一個特定的基類。所有的測試必須遵從這個
介面,因此測試客戶所看到的就是這個介面:
public interface Test {
public abstract void run(TestResult result);
}
Leaf所代表的簡單TestCase實現了這個介面,我們前面已經討論過了。
下面,我們討論Composite,即組合測試案例,稱為測試套件(TestSuite)。TestSuite用Vector來存
放他的孩子(child test):
public class TestSuite implements Test {
private Vector fTests= new Vector();
}
測試套件的run()方法委託給它的孩子,即依次調用它的孩子的run()方法:
public void run(TestResult result) {
for (Enumeration e= fTests.elements(); e.hasMoreElements(); ) {
Test test= (Test)e.nextElement();
test.run(result);
}
}
圖5:測試套件應用了composite模式
測試客戶要向測試套件中添加測試,調用addTest方法:
public void addTest(Test test) {
fTests.addElement(test);
}
注意,上面的代碼是如何依賴於Test介面的。既然TestCase和TestSuite都遵從同一個Test介面,因此
測試套件可以遞迴的包含測試案例和測試套件。開發人員可以建立自己的TestSuite,並用這個套件運行其
中所有的測試。
這是一個建立TestSuite的例子:
public static Test suite() {
TestSuite suite= new TestSuite();
suite.addTest(new MoneyTest("testMoneyEquals"));
suite.addTest(new MoneyTest("testSimpleAdd"));
}
〔begin 為有助於理解,此處為譯者添加〕
以上代碼中,suite.addTest(new MoneyTest("testMoneyEquals"))表示向測試套件suite中添加一個測
試,指定測試類別為MoneyTest,測試方法為testMoneyEquals(由selector選定該方法,與模板方法
runTest對接)。
在MoneyTest類中沒有聲明MoneyTest(String)的構造器,那麼MoneyTest(“testMoneyequals”)執行時調
用super(String)構造器,它定義於MoneyTest的父類TestCase中。
TestCase(此處也即MoneyTest)把“testMoneyEquals”字串存放在私人變數中,這個變數是一個
方法指標,使用的是Pluggable Selector模式,表明它所指定的方法testMoneyEquals要與模板方法runTest
對接。表明該測試案例執行個體中起作用的是testMoneyEquals(),利
用Java的反射特性實現對該方法的調用。
因此以上代碼向suite中添加了2個測試執行個體,類型均為MoneyTest,但測試方法不同。
〔end 為有助於理解,此處為譯者添加〕
這個例子工作很好,但要我們手工添加所有的測試,這是很笨的辦法,當你編寫一個測試案例時,你
要記得把它們添加到一個靜態方法suite()中,否則它就不會運行。為此,我們為TestSuite增加了一個構
造器,它用測試案例的類作為其參數,它的作用就是提取這個類中的所有測試方法,並建立一個測試套
件,把這些提取出來的測試方法放進所建立的測試套件中。但這些測試方法要遵守一個簡單的協定,即方
法命名以“test”作為首碼,且不帶參數。這個構造器利用這個協定,使用Java的反射特性找出測試方
法,並構建測試對象。如果使用這個構造器,上面的代碼就很簡單:
public static Test suite() {
return new TestSuite(MoneyTest.class);
}
即為MoneyTest類中中的每一個testXxx方法都建立一個測試執行個體。〔此處為譯者添加〕
但前一種方式仍然有用,比如你只想運行測試案例的一個子集。
3.6、概要
JUnit的設計到此告一段落。顯示了JUnit設計中使用的模式。
圖6:JUnit中的模式
注意TestCase(JUnit架構中的核心功能)參與了4個模式。這說明在這個架構中,TestCase類是“模
式密集(pattern density)”的,它是架構的中心,與其它支援角色有很強的關聯。
下面是查看JUnit模式的另外一個視角。在這個情節圖中,你依次看到每個模式所帶來的效果。
Command模式建立了TestCase類,Template Method模式建立了run方法,等等。這裡所用的符號都來自
圖6,只是去掉了文字。
圖7:JUnit中的模式情節板
要注意一點,當我們應用Composite模式時,複雜性突然增加了。Composite模式功能很強大,使用當
心。
4、結論
為了得出結論,我們作一些一般的觀察:
· 模式
以前,當我們開發架構和試圖向其它人解釋架構時,我們發現用模式來討論設計是無用的。現在,你處於
一個極好的處境來判斷用模式來描述架構是否有效,如果你喜歡上述討論,那麼也用這樣的方式來表示你
的系統。
· 模式密集度
圍繞著TestCase有很高的模式密集度,TestCase是JUnit設計中的關鍵抽象,它便於使用,但難以改變。我
們發現圍繞關鍵抽象有很高的模式密集度,是成熟架構的普遍現象。對於不成熟的架構,情形相反,它們
模式密集度不高。一旦你發現你要解決的是什麼問題,你就開始“濃縮”你的解決方案,達到高的模式密集度。
· Eat your own dog food
As soon as we had the base unit testing functionality implemented, we applied it ourselves.
A TestTest verifies that the framework reports the correct results for errors, successes,
and failures. We found this invaluable as we continued to evolve the design of the
framework. We found that the most challenging application of JUnit was testing its own
behavior.
· 交集,而非合并
在架構開發中,總想包含進每一個特性,想讓架構儘可能有價值,但有另一個因素作用相反:你希望開發
人員使用你的架構。架構的特性越少,學習就越容易,開發人員就越可能使用它。JUnit的設計就是這樣
的思路,它實現那些對於運行測試而言是必不可少的特性,如運行測試套件、將不同的測試互相隔離、自
動運行測試等等。當然我們還會添加新的特性,但我們會仔細地加以選擇,並把它們放進JUnit擴充包中。
在擴充包中,一個值得注意的成員就是TestDecorator類,它使用了Decorator模式,可以在測試代碼運行
之前或運行之後執行其它的代碼。〔此處譯者有添加〕
· 架構作者要花很多時間閱讀架構代碼
我們閱讀架構代碼的時間要比編寫代碼的時間多得多;我們為架構增加功能,但我們花同樣多的時間為刪
除架構中的重複功能。我們用各種途徑為架構設計、增加類、移動類職責,只要我們能考慮到的各種途
徑。在JUnit、測試、對象設計、架構開發和寫文章的工作中,我們不斷地提高洞察力,並受益無窮。