測試的感染:程式設計師喜歡寫測試

Test Infected: 
Programmers Love Writing Tests

JUnit 
 

原作者:Kent Beck, CSLife  Erich Gamma, OTI Zürich

翻譯:Areca Chen 2008/08/23

測試不是緊密的與開發整合在一起。這樣會妨礙你衡量開發進度--你無法告訴別人某些事開始執行或某些事已停止。使用JUnit你可以便宜的 以漸進方式建構一個測試系列(包)(suite),此測試系列可以幫你衡量你的進度,指出非預期的邊際效應,並聚焦於你的開發努力。

內容 Contents

問題The Problem

每一個程式設計師知道他們應該為程式碼撰寫測試。但很少人做。為什麼不做?共同的回答是「時間太緊迫。」很快的這便成一種惡性循環--你愈感受到壓力,你就寫愈少的測試。你寫的測試愈少,你的生產力愈低而且程式碼的穩定性變得愈差。生產力及準確性愈差,你感受的壓力愈大。

由於這種惡性循環使得程式設計師不在熱衷於其工作。要打破這個惡性循環需要外在的影響力。我們發現這個外在的影響力所需要的是一個簡單的測試框架讓我們做一些測試而能夠有重大的改變。

要說服你寫測試是值得;最好的方法就是坐在你旁邊做一些開發工作。以這種方式,我們先找到新的臭蟲,使用測試抓住臭蟲,修正臭蟲,然後再回頭 ,再修正,如此循環幾次。你將發現撰寫測試及救援並重複執行你的單元測試可以獲得的價值--立即回饋。

很不幸的,這只是一篇論文,不是一個俯視迷人老城Zürich的辦公室,因此我們以模擬開發的方式來告訴你。我們將寫一個簡單的程式及測試,以顯示執行測是的結果。以這種方式你將感受到我們所使用的程序並擁護這種方式。

範例Example

當你閱讀時,請注意程式碼及測試之間的互動關係。於此我們開發的風格是寫幾行程式碼,然後撰寫一個應該 可以執行的測試,或更佳的是,寫一個測試可能是不能執行的,然後撰寫程式碼以讓測試可以執行。(譯註1

我們所寫的這個程式解決的問題是多種幣值(currencies)的算術運算。單一貨幣之間的運算沒有甚麼問題,你只要直接加減即可。你可以忽略幣別的表示。

但 當包含多種幣別時事情變得很有趣。我們不能只是轉換一種幣別成另外一種來做算數運算因為沒有單一的兌換率--你可能需要比對昨日及今日的兌換率。

讓我們簡單的開始並定義一個類別Money代表單一幣別的值。我們以簡單的整數值代表金額。若要獲得完整的幣值你可能需要使用double或java.math.BigDecimal來儲存 帶符號任意長度小數值的數字。我們以字串儲存幣別;其中是以ISO方式的三個字母縮寫代表幣別(如USD、CHF等)。在比較複雜的實作方式,幣別可能需要是一個物件。

class Money {
    private int fAmount;
    private String fCurrency;
    public Money(int amount, String currency) {
        fAmount= amount;
        fCurrency= currency;
    }

    public int amount() {
        return fAmount;
    }

    public String currency() {
        return fCurrency;
    }
}

當你把兩個相同幣別的Money相加,其產生新的Money的金額是把另外兩個物件的金額加總。

public Money add(Money m) {
    return new Money(amount()+m.amount(), currency());
}

現在,不只是撰寫程式碼,我們要獲得立即的回饋並實踐「寫一點程式,寫一些測試,寫一點程式,寫一些測試」。我們使用JUnit框架實作我們的測試。要撰寫測試你需要取得JUnit的最新版本(或者你自行撰寫你自己的測試框架--那不是那麼容易達成。)

JUnit定義如何架構你的測試案例(test cases)並提供執行的工具。你實作一個測試的方式是從TestCase類別繼承。要測試我們的Money實作我們定義MoneyTest這個TestCase的 子類別。在Java中,類別是包含於包裹(package)中因此我們必須決定要把MoneyTest放在何處。 我們目前的作法是把MoneyTest放在與要測試的類別同一個包裹中。這種方式測試案例可以存取包裹中其他類別的私有(private)的方法。在MoneyTest中我們新增一個testSimpleAdd()方法,testSimpleAdd()將檢測上述Money.add()方法的簡單版本。JUnit的測試方法是普通(ordinary)方法沒有任何參數。

public class MoneyTest extends TestCase {
    //…
    public void testSimpleAdd() {
        Money m12CHF= new Money(12, "CHF");  // (1)
        Money m14CHF= new Money(14, "CHF");        
        Money expected= new Money(26, "CHF");
        Money result= m12CHF.add(m14CHF);    // (2)
        assert(expected.equals(result));     // (3)
    }
}

testSimpleAdd()測試案例包含:

  1. 程式碼(1)建立測試中我們要與之互動的物件。這個測試背景(context)一般稱之為測試的固定裝置(fixture)。在testSimpleAdd()中我們需要的是Money物件。
  2. 程式碼(2)檢測在固定裝置中的物件。
  3. 程式碼(3)審查(verify)結果。

在可以審查結果之前我們要暫時脫離一下主題;因為我們需要測試兩個Money物件是相等(equal)的情況。Java習慣作法是覆載(override)定義於Object中的equals()方法。在實作equals()之前我們先在MoneyTest中寫一個equals()的測試。

public void testEquals() {
    Money m12CHF= new Money(12, "CHF");
    Money m14CHF= new Money(14, "CHF");

    assert(!m12CHF.equals(null));
    assert(m12CHF, m12CHF);
    assertEquals(m12CHF, new Money(12, "CHF")); // (1)
    assert(!m12CHF.equals(m14CHF));
}

在Object中的equals()方法是當兩個物件是相同時傳回真值。但是Money是一個值(value)的物件。當他們的幣別及金額是一樣的時候視為相等。要測試這種屬性我們增加一個測試(1)來評定兩個Money是否相等;其要件是擁有相同的值而不一定是相同的物件。

接下來讓我們在Money中撰寫equals()方法:

public boolean equals(Object anObject) {
    if (! anObject instanceof Money)
        return false;
    Money aMoney= (Money)anObject;
    return aMoney.currency().equals(currency())
        && amount() == aMoney.amount();
}

因為equals()可以接受任何型別的物件作為參數因此在我們將此參數轉換(cast)成Money之前我們需先檢查其型別(type)。暫時離題,任何時候覆載equals()時在實務上建議還要覆載hashCode()方法。不管如何;我們要回到我們的測試案例。

現在我們手上有了equals()方法我們可以檢核testSimpleAdd()方法。在JUnit中一般是呼叫從TestCase繼承下來的assert()。assert()在其參數值不為真 時觸發(trigger)一個失敗並由Junit紀錄這個失敗。因為評斷是否相等是非常通常的,TestCase有定義了一個方便的assertEquals()方法。此外equals()當其兩個參數不相等時紀錄兩個物件的列印值(printed value)(譯註2)。如此可以讓我們立即在JUnit的測試結果報告中看到為什麼測試失敗。這個列印值是由toString()轉換方法建立並顯示成為字串。

現在我們已實作兩個測試案例;我們發現在設定(setting-up)測試時有些重複的程式碼。我們最好可以重複使用此測試的設定程式碼。也就是說;我們希望有一個共通的固定裝置來執行測試。在JUnit你可以經由提供固定裝置的物件作為TestCase類別的實例變數(instance variables)並以覆載的setUp()方法來初始化。另一個與setUp()方法對稱的操作是tearDown();你可以覆載tearDown()方法以供在測試完成後清除測試的固定裝置。每一個測試在其自己的固定裝置中執行而Junit為每一測試呼叫setUP()及tearDown()如此可以避免測試執行導致副作用。

public class MoneyTest extends TestCase {
    private Money f12CHF;
    private Money f14CHF;   

    protected void setUp() {
        f12CHF= new Money(12, "CHF");
        f14CHF= new Money(14, "CHF");
    }
}

我們可以重寫這兩個測試案例,將其中共通的設定程式碼移除:

public void testEquals() {
    assert(!f12CHF.equals(null));
    assertEquals(f12CHF, f12CHF);
    assertEquals(m12CHF, new Money(12, "CHF"));
    assert(!f12CHF.equals(f14CHF));
}

public void testSimpleAdd() {
    Money expected= new Money(26, "CHF");
    Money result= f12CHF.add(f14CHF);
    assert(expected.equals(result));
}

執行這兩個測試案例有兩種方式:

  1. 定義如何執行一個個別的測試案例。
  2. 定義如何執行一個測試系列。

JUnit提供兩種執行單一測試的方式:

靜態的方式:你可以覆載從TestCase繼承的runTest()方法並呼叫你要測試的測試案例。有一個便利的方法就是使用一個匿名的內部類別(anonymous inner class)。注意每一個測試必須有一個名稱,以便在測試失敗時可以找出是屬於那一個方法失敗。

TestCase test= new MoneyTest("simple add") {
    public void runTest() {
        testSimpleAdd();
    }
};

在父類別中有一個樣版方法樣式[1] 可以確保runTest()在適當的時候執行。 (譯註3

動態的方式:建立一個要執行的測試案例使用使用回應(reflection)實作runTest。它假設測試的名稱是測試案例啟動的方法名稱。它動態的找尋並啟動測試方法。要啟動testSimpleAdd()測試我們構建一個MoneyTest如下:

TestCase = new MoneyTest("testSimpleAdd");//把要測試的方法名稱作為MoneyTest的參數。

動態的方式是比較簡潔的但比靜態方法危險。測試案例中的名稱錯誤會等到執行測試時才會出現一個NoSuchMethodException的例外訊息。

最後一步要讓兩個測試案例一併執行,我們必須定義一個測試系列。在JUnit中需要定義一個靜態方法稱為suite()。suite()方法就像Main()方法 一樣;只是Siute()專門執行測試。在suite()中你把你要的測試加到一個TestSuite物件中並傳回這個TestSuite物件。TestSuite物件可以執行一 組測試。TestSuite及TestCase都時實作Test介面,在Test介面中已定義執行測試的方法。如此可以讓測試系列由任意的TestCase及TestSuite所組成。 簡而言之TestSuite是一個合成物件樣式(Composite)[1]。下面的程式碼說明建立一個測試系列並以動態方式執行一個測試。

public static Test suite() {
    TestSuite suite= new TestSuite();
    suite.addTest(new MoneyTest("testMoneyEquals"));
    suite.addTest(new MoneyTest("testSimpleAdd"));
    return suite;
}

此處是使用靜態方式的相關程式碼。

public static Test suite() {
    TestSuite suite= new TestSuite();
    suite.addTest(
        new MoneyTest("money equals") {
            protected void runTest() { testMoneyEquals(); }
        }
    );
    
    suite.addTest(
        new MoneyTest("simple add") {
            protected void runTest() { testSimpleAdd(); }
        }
    );
    return suite;
}

現在我們可以執行我們的測試了。JUnit提供圖形界面執行測試。在視窗上部的欄位中輸入你的測試類別名稱。按下Run按鈕。當測試執行當中JUnit位於輸入欄位下方以一個進度桿顯示執行進度。這個進度桿初始是綠色,但當測試不成功時會變成紅色。失敗的測試會顯示在下方的列表中。圖1顯示TestRunner執行的畫面。

圖1:執行成功的畫面

在完成簡單的幣別案例的證實之後我們進一步做多幣別的案例。在上面我們提過有關多幣別混合計算的問題是沒有單一的兌換率。為避免這個問題我們引入一個MoneyBag類別其中定義兌換率的換算。例如把12瑞士法郎(CHF)加到14美金(USD)以一個錢包(bag)包含兩種幣別即12CHF及14USD。另外加入10瑞士法郎則變成22CHF及14USD。之後我們可以使用不同的兌換率求出MoneyBag的金額。

MoneyBag中有多個Money而以一個列表(list)表示;並提供不同的建構函數建構MoneyBag。要注意的是建構函數在包裹中是私有因為MoneyBag是被建構在執行幣值算 術計算的場景下。

class MoneyBag {
    private Vector fMonies= new Vector();

    MoneyBag(Money m1, Money m2) {
        appendMoney(m1);
        appendMoney(m2);
    }

    MoneyBag(Money bag[]) {
        for (int i= 0; i < bag.length; i++)
            appendMoney(bag[i]);
    }
}

appendMoney()方法是一個內在的輔助方法;appendMoney()把一個Money加到fMonsys的列表 (陣列)中並留意把同一幣別加在一起。MoneyBag也需要一個equals()方法以便與相關測試共同合作。我們跳過equals()的實作只顯示testBagEquals()方法。首先我們擴充固定裝置以包含兩個MoneyBags。

protected void setUp() {
    f12CHF= new Money(12, "CHF");
    f14CHF= new Money(14, "CHF");
    f7USD=  new Money( 7, "USD");
    f21USD= new Money(21, "USD");
    fMB1= new MoneyBag(f12CHF, f7USD);
    fMB2= new MoneyBag(f14CHF, f21USD);
}

加上固定裝置此testBagEquals()測試案例變成:

public void testBagEquals() {
    assert(!fMB1.equals(null));
    assertEquals(fMB1, fMB1);
    assert(!fMB1.equals(f12CHF));
    assert(!f12CHF.equals(fMB1));
    assert(!fMB1.equals(fMB2));
}

依循「寫一點程式,寫一些測試」我們使用JUnit執行我們擴充的測試以確認我們做的仍然沒問題。有了MoneyBag,我們可以修正Money中的add()方法。

public Money add(Money m) {
    if (m.currency().equals(currency()) )
        return new Money(amount()+m.amount(), currency());
    return new MoneyBag(this, m);
}

上述的定義這個方法無法編譯因為其期望傳回的應該是一個Money而不是MoneyBag。為了引入MoneyBag現在有兩種 金錢的表示法;其中我們希望隱藏程式碼不讓使用端看到。要如此我們引入一個IMoney介面而讓兩種金錢的表示法實作其方法。下面是IMoney的介面宣告:

interface IMoney {
    public abstract IMoney add(IMoney aMoney);
    //…
}

為充分隱藏不同的表示法我們必須提供介於Money及MoneyBag間不同的結合的計算。在開始撰寫程式前,我們為此定義一些測試案例。MoneyBag建構函數期望的結果便 是使用前面所述方便的建構函數,即以一個矩陣初始化MoneyBag。

public void testMixedSimpleAdd() { 
    // [12 CHF] + [7 USD] == {[12 CHF][7 USD]} 
    Money bag[]= { f12CHF, f7USD }; 
    MoneyBag expected= new MoneyBag(bag); 
    assertEquals(expected, f12CHF.add(f7USD)); 
}

其他的測試依循相同的樣式:

接著,我們相對的擴充我們的測試系列:

public static Test suite() {
    TestSuite suite= new TestSuite();
    suite.addTest(new MoneyTest("testMoneyEquals"));
    suite.addTest(new MoneyTest("testBagEquals"));
    suite.addTest(new MoneyTest("testSimpleAdd"));
    suite.addTest(new MoneyTest("testMixedSimpleAdd"));
    suite.addTest(new MoneyTest("testBagSimpleAdd"));
    suite.addTest(new MoneyTest("testSimpleBagAdd"));
    suite.addTest(new MoneyTest("testBagBagAdd"));
    return suite;
}

完成測試案例的定義我們可以開始實作它們。在此處我們要質疑實作的是處理Money及MoneyBag之間各種不同的結合。雙重分派(Double Dispatch)[2]是解決這個問題的一個漂亮方法。雙重方派的概念是使用一個額外的呼叫來檢查我們處理的參數的種類。我們呼叫一個方法其參數使用原來的方法的名稱接著接收者的類別名稱。在Money及MoneyBag中的add()方法變成這樣:

class Money implements IMoney {
    public IMoney add(IMoney m) {
        return m.addMoney(this);
    }
    //…
}
class MoneyBag implements IMoney {
    public IMoney MoneyBag.add(IMoney m) {
        return m.addMoneyBag(this);
    }
    //…
}

為了通過編譯我們需要擴充IMoney的介面加上兩個輔助方法:

interface IMoney {
//…
    IMoney addMoney(Money aMoney);
    IMoney addMoneyBag(MoneyBag aMoneyBag);
}

要完成實作雙重分派,我們必須實作Money及MoneyBag中的方法。下面是實作Money中的部份:

public IMoney addMoney(Money m) {
    if (m.currency().equals(currency()) )
        return new Money(amount()+m.amount(), currency());
    return new MoneyBag(this, m);
}

public IMoney addMoneyBag(MoneyBag s) {
    return s.addMoney(this);
}

接下來是在MoneyBag中的實作其中假設額外的建構函數是從一個Money及一個MoneyBag與從兩個MoneyBag建構一個MoneyBag。

public IMoney addMoney(Money m) {
    return new MoneyBag(m, this);
}

public IMoney addMoneyBag(MoneyBag s) {
    return new MoneyBag(s, this);
}

我們執行測試結果都通過測試。不管如何,從實作反映出來的我們發現另一個有趣的情況。如果加上一個MoneyBag其結果是錢包中只有一種幣別時會如何。例如把一個-12CHF加到一個包含7USD及12CHF的結果只有7USD。很明顯的這個錢包應該與只有7USD的Money相等。要修正這個問題讓我們實作一個測試並執行它。

public void testSimplify() {
    // {[12 CHF][7 USD]} + [-12 CHF] == [7 USD]
    Money expected= new Money(7, "USD");
    assertEquals(expected, fMS1.add(new Money(-12, "CHF")));
}

當你是以這種風格開發程式;當你有一個想法那麼立即將之轉換成一個測試,而不只是直接撰寫程式。

毫無意外的我們執行測試的結果顯示出紅色的進度桿表示測試失敗。因此我們修復MoneyBag中的程式碼使之回到綠色的進度桿。

public IMoney addMoney(Money m) {
    return (new MoneyBag(m, this)).simplify();
}

public IMoney addMoneyBag(MoneyBag s) {
    return (new MoneyBag(s, this)).simplify();
}

private IMoney simplify() {
    if (fMonies.size() == 1)
        return (IMoney)fMonies.firstElement()
    return this;
}

現在我們再執行我們的測試,瞧;結果是綠色的。

上述的程式碼只是解決多幣別計算的部分問題。我們必須展示不同的兌換率、列印格式及其他的算術計算,而且以合理的速度進行。不管如何,我們希望你可以看到你如何可以開發其餘的物件 ;每次都先加上一個測試;寫一點程式,寫一些測試,寫一點程式,寫一些測試。

特別地;回顧前面我們是如何開發:

測試手冊Cookbook

OK,你已完全信服單元測試是一個最難以置信的概念。你如何撰寫你的測試。這裡有一個簡短的測試手冊告訴你一步步循序漸進使用JUnit撰寫及組織你自己的測試:

簡單的測試案例Simple Test Case

你如何撰寫測試碼?

最簡單的方式就是除蟲的方式表達。你可以改變除蟲的表達而無須重新編譯,而且你可以等到你已看到執行中的物件才決定要寫甚麼。你也可以撰寫測試表達成陳述以供列印到標準的輸出串流(stream)。這兩種測試的風格有其限制因為都需要人工的判斷 來分析其結果。同時;其組織都不是很好--你只能一次執行一個除蟲表示式而且一個程式中有太多列印陳述導致令人畏懼的『捲軸盲點(Scroll Blindness)』

JUnit測試無須人工的判斷來解釋,而且很容易的同時執行許多測試。當你需要測試某些東西,下面是你該做的:

  1. 建立一個TestCase的實例物件:
  2. 覆載runTest()方法;
  3. 當你要檢查一個值,呼叫assert(),若測試成功傳回一個真值。

例如;測試兩個擁有相同的幣別的Money所含的值其相加結果是正確的;其測試碼如下:

public void testSimpleAdd() {
    Money m12CHF= new Money(12, "CHF"); 
    Money m14CHF= new Money(14, "CHF"); 
    Money expected= new Money(26, "CHF"); 
    Money result= m12CHF.add(m14CHF); 
    assert(expected.equals(result));
}

如果你要寫的測試類似你已寫過的其中之一,寫一個固定裝置。當你要執行不只一個測試,寫一個測試系列

固定裝置Fixture

如果你有兩個以上的測試中有相同或類似的物件時可以做甚麼事?

測試需要依靠一組已知的物件為背景來執行。這組物件稱之為測試固定裝置。當你撰寫測試你將發現你花太多時間撰寫程式碼設定這些固定裝置多於做實際對測試有用的工作。

在某種程度上,只要你小心注意你寫的構建函數你就可以很容易的撰寫固定裝置。不管如何;共享固定裝置可以大大的節省你的時間。你往往可以在許多不同的測試中使用相同的固定裝置。每一種情況都可以透過傳遞一點不同的訊息或參數給固定裝置而檢測不同的結果。

當你要有一個通用的固定裝置,下面是你要做的:

  1. 建立一個TestCase子類別。
  2. 在每一部份的固定裝置中加入實例變數(instance variable)。
  3. 覆載『setUp()』以初始化這些實例變數。
  4. 覆載『tearDown()』以釋放你在setUp()中使用的資源。

例如,要寫許多測試案例;在這些測試案例中使用12CHF、14CHF及28USD的不同結合,此時先建立一個固定裝置:

public class MoneyTest extends TestCase { 
    private Money f12CHF; 
    private Money f14CHF; 
    private Money f28USD; 
    
    protected void setUp() { 
        f12CHF= new Money(12, "CHF"); 
        f14CHF= new Money(14, "CHF"); 
        f28USD= new Money(28, "USD"); 
    }
}

當你有了這些固定裝置,你可以隨意寫多少個測試案例

測試案例Test Case

當你有一個固定裝置;你如何撰寫並啟動一個個別的測試案例?

在沒有固定裝置的情況下寫測試案例是很簡單的--覆載TestCase匿名子類別中的runTest()。若有固定裝置撰寫測試案例也是相同的方式,就是為你的設定碼實作一個TestCase的子類別;然後為個別的測試案例實作一個匿名子類別。不管如何,經過幾個測試之後你將注意到你的程式碼大部分都是奉獻給語法。

JUnit提供使用固定裝置更方便的撰寫測試方式,下面是你要做的:

  1. 在固定裝置類別中撰寫測試案例方法,請記得這些方法都是公開的,否則無法經由回應(reflection)啟動。
  2. 建立一個TestCase類別的實例物件並傳遞測試案例方法的名稱給這個構建函數。

例如,測試Money及MoneyBag的相加:

public void testMoneyMoneyBag() { 
    // [12 CHF] + [14 CHF] + [28 USD] == {[26 CHF][28 USD]} 
    Money bag[]= { f26CHF, f28USD }; 
    MoneyBag expected= new MoneyBag(bag); 
    assertEquals(expected, f12CHF.add(f28USD.add(f14CHF)));
}

構建一個MoneyTest的實例物件來執行測試案例:

new MoneyTest("testMoneyMoneyBag")

當測試被執行,測試名稱被用來尋找於要執行的方法。

當你有許多測試,把它們組織成一個測試系列

測試系列Suite

你如何一次執行許多測試?

只要你有兩個測試,你將需要同時執行它們。你可以自己一次執行一個,但你馬上會感到厭煩。JUnit提供一個物件;TestSuite一次可以執行任意數量之TestCase。

例如,執行單一測試案例:

TestResult result= (new MoneyTest("testMoneyMoneyBag")).run();

構建一個測試系列一次執行兩個測試案例:

TestSuite suite= new TestSuite();
suite.addTest(new MoneyTest("testMoneyEquals"));
suite.addTest(new MoneyTest("testSimpleAdd"));
TestResult result= suite.run();

TestSuite不只可以包含TestCase。測試系列可以包含任何實作Test介面的物件。例如;你可以在你的程式碼中建立一個TestSuite而我可以建立一個我的,我可以建立一個TestSuite一次執行兩個測試系列:

TestSuite suite= new TestSuite();
suite.addTest(Kent.suite());
suite.addTest(Erich.suite());
TestResult result= suite.run();

測試執行者TestRunner

你如何執行你的測試並收集執行結果?

當你有一個測試系列而你要執行它。JUnit提供工具定義要執行的測試系列並且顯示其結果。使用一個靜態方法suite()傳回一個測試系列讓TestRunner可以存取。例如,讓MoneyTest測試系列可以讓TestRunner可以存取,在MoneyTest加入下列的程式碼:

public static Test suite() { 
    TestSuite suite= new TestSuite(); 
    suite.addTest(new MoneyTest("testMoneyEquals")); 
    suite.addTest(new MoneyTest("testSimpleAdd")); 
    return suite;
}

JUnit提供圖形及文字介面的TestRunner工具。啟動測試執行者的方式是輸入java test.ui.TestRunner。圖形使用者介面顯示的視窗是:

當測試不成功JUnit將失敗的測試列示於下方的列表中。JUnit區分失敗(failures)及錯誤(errors)。失敗是由評估預期的結果與檢測的結果。錯誤是非預期的問題如ArrayIndexOutOfBoundsException圖2顯示一個失敗的測試範例。

2:一個不成功的執行

要知道更多有關失敗或錯誤的情況可以在列表中選擇要瞭解的項目並按下Show按鈕,這將顯示失敗執行的追溯,如圖3

3: 失敗的細節

此外你可以不關掉JUnit視窗。每次編譯程式後按下Run按鈕JUnit將載入你的測試最後版本。

JUnit也有一個批次介面。在作業系統的指令列輸入Java test.textui.TestRunner後面接著類別名稱及一個測試系列方法。這個批次介面以文字顯示結果。另一種方式是在你的TestCase定義一個main()方法。

例如;啟動MoneyTest的批次TestRunner:

public static void main(String args[]) { 
    test.textui.TestRunner.run(suite());
}

使用main()的定義你可以在作業系統的指令列輸入java MoneyTest執行你的測試。

不管使用圖形或文字介面請確認test.jar檔案存在於你的CLASSPATH中。

測試實務Testing Practices

Martin Fowler讓你的測試更容易。他說「任何時候你想要在螢幕輸出某些陳述或除蟲的表示式,請寫一個測試取代這個動作。」一開始你會發現你一直需要建立一個新的固定裝置,同時測試似乎拖慢進度。不管如何很快的你開始再使用你的固定裝置程式庫而新的測試通常將變成簡單的只是把方法加入到現有的TestCase子類別中。

你可以隨時撰寫新的測試,不管如何,你很快的會發現只有一些測試你可以想像的是實際上有用的。你所要的測試是你認為應該可以成功卻失敗的,或你認為應該失敗卻執行成功的。另一種思考的方式是本益比。你寫的測試是可以獲得資訊回饋的。

這裡列出有些時候你可以從測試投資獲得的合理回報:

關於你的測試我想告誡你一句話。當你已讓測試可以執行,請確保它們可以繼續執行。讓你的測試系列執行(having your suite running)與讓它失敗( having it broken)之間有極大的差異。理想上,每次你變動一個方法你應該執行測試系列中所有的測試。實際上,你的測試系列將快速成長的太大以致於無法隨時執行。嘗試最佳化你的設定碼以便可以執行所有的測試。或者;至少,建立特別的測試系列其中列包含可能影響你目前開發工作的所有測試。然後每次編譯時執行此測試系列。並且確認你每天至少執行所有的測試一次:晚上,午餐或者在一個長時間的會議時.....

結論Conclusion

本文只是接觸到測試的表層。不管如何,是聚焦於測試的風格,這個風格很明顯的顯示出小小的投資可以讓你成為更快速、更有生產力、更可預測(predictable)而更沒有壓力的開發者。

一旦你受到測試的感染,你對於開發的態度將會改變。這裡有一些改變是我們注意到的:

在測試全部執行結果正確及測試結果不正確之間有極大的差異。有一部份受測試的影響的是如果你的測試無法達到100%你便無法回家。儘管如果你一小時執行你的測試系列十次或一百次,你也不至於造成足夠的大災難讓你的晚餐延後。

有時你只是感覺不想要寫測試,尤其是一開始。別這樣。不管如何,注意你會陷入更糟糕的情境,你花在除蟲的時間會增加多少,及當你沒有測試時你會感受到多大的壓力。我們曾經驚訝於程式設計有多大的樂趣及我們願意承擔更多的挑戰及當我們有測試的支援時我們降低的壓力有多少。這些差異是如此的戲劇化足以讓我們持續撰寫測試;既使我們不喜歡。

一旦你有了測試你將能夠更積極的做重整(refactor)。一開始你不瞭解你可以做的有多少,儘管如此,試著對你自己說「喔,我知道,我應該 已經設計這個如此這般。我現在無法改變了。我不想損毀任何東西。」當你這麼說的時候,把你目前版本的程式碼儲存成另一個副本並讓你自己有幾個小時的時間清理。(這部分的工作你最好有一個伙伴在後面看著你的工作。)改變你的程式碼,一直執行你的測試?如果你不會一直擔心你可能被損毀的任何聲音;你會對你在幾小時內可以涵蓋的範圍有多少感到訝異。

例如,我們從以陣列為基礎實作MoneyBag切換到以雜湊表(HashTable)為基礎的實作。我們可以很快的切換並且非常安心;因為我們已有這麼多的測試可以依賴。如果所有的測試都可以執行成功,我們可以確認我們一點也沒有變動系統產出的答案。

你將會希望你的團隊的其他人也寫測試。我們找到感染測試最好的方式是透過接觸的指導(direct contact)。下次有人要求你幫他除蟲,讓他們就一個固定設備及期望結果的方面說說其問題。然後說「我希望以一種我們可以使用的格式寫下你剛才告訴我的。」讓他們看著你寫一些測試,執行它,修復它,寫另外一個。很快的他們就會寫他們自己的測試 了。

因此--嘗試JUnit。如果你把它變的更好,請把變動的部分傳給我們以便我們可以廣泛的散佈出去。我們的下一篇文章將討論Junit框架本身。我們將顯示它是如何建構完成的,並說明一些有關我們的框架開發的哲學。

我要感謝Martin Fowler,他是一個好的程式設計師是任何分析師都希望的,由於他的評論非常有助於JUnit早期的版本。

參考References

  1. Gamma, E., et al. Design Patterns: Elements of Reusable Object-Oriented Software, Addison-Wesley, Reading, MA, 1995
  2. Beck, K. Smalltalk Best Practice Patterns, Prentice Hall, 1996

譯註1:此處說明的程序應該是先寫介面,再寫測試,最後再加入介面的實作,而最終這個實作是可以通過測試的。

譯註2:列印值即相關測試失敗的資料如失敗的物件及失敗的內容,經過JUnit的紀錄可以顯示於螢幕上供判別之用。

譯註3:樣版方法樣式是定義一個樣版類別,這個類別在執行期一需要可以一個匿名的內部類別取代以供執行特定的測試類別,本例中test類別即是匿名的內部類別。

本文獲授權同意翻譯函:

 

I would be honored if you would spend the time to translate my poor papers into Chinese and put them on the net.
 
Kent

 

Dear Kent,
I've read the articles "Test Infected:  Programmers Love Writing Tests" and " Aim, Fire"on the web site and like those very much.
It will be great if I could translate those into traditional Chinese and publish on our own site http://www.dotspace.idv.tw) to help those who want to explore XP more. I will, of course, declare the source and "hyperlink" those to your original documents. I'll be very grateful if you could give your authorization for me to do this.
Our web site contents about software engineering topics like XP, UML, Rup, Design Patterns, Framework, those are all in traditional Chinese.
        Regards,
Areca Chen
2002/2/8