天天看點

[轉]JUnit的架構設計及其使用的設計模式

原文: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所示。

[轉]JUnit的架構設計及其使用的設計模式

圖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所示。

[轉]JUnit的架構設計及其使用的設計模式

圖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所示。

[轉]JUnit的架構設計及其使用的設計模式

圖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模式。

[轉]JUnit的架構設計及其使用的設計模式

圖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); 

    } 

}

[轉]JUnit的架構設計及其使用的設計模式

圖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)的構造器,那麼oneyTest(“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設計中使用的模式。

[轉]JUnit的架構設計及其使用的設計模式

圖6:JUnit中的模式

    注意TestCase(JUnit架構中的核心功能)參與了4個模式。這說明在這個架構中,TestCase類是“模式密集(pattern density)”的,它是架構的中心,與其它支援角色有很強的關聯。

    下面是檢視JUnit模式的另外一個視角。在這個情節圖中,你依次看到每個模式所帶來的效果。

Command模式建立了TestCase類,Template Method模式建立了run方法,等等。這裡所用的符号都來自圖6,隻是去掉了文字。

[轉]JUnit的架構設計及其使用的設計模式

圖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、測試、對象設計、架構開發和寫文章的工作中,我們不斷地提高洞察力,并受益無窮。

轉:JUnit的架構設計及其使用的設計模式