Java程式的單元測試Unit Testing Java Programs

by Keld H. Hansen

翻譯:Areca Chen

程式碼測試可能是非常乏味的,尤其是測試別人的程式,而當你是一個程式設計師的時候尤甚。但程式設計師喜歡撰寫程式,因此為什麼不讓程式設計師撰寫一些程式可以作為測試之用?這是自動測試背後的概念,也是本文要討論的。

自動單元測試(一個屬於普通的Java類別的單元)並不是一個新的事物。有些人在大型系統中已經用了許多年,而且始終都是很有用的。自動單元測試及撰寫並執行特別的測試程式的差異如下:

當單元測試正確的實作可以幫助程式設計師變的更有生產力,而同時提升開發程式碼的品質。有一點你必須瞭解的是單元測試應該是開發程序的一部份是很重要的;而且程式碼的設計必須是可以測的。目前的趨勢是在撰寫程式碼之前要先撰寫單元測試,並且把焦距放在你Java類別的介面及行為。在本文中我們要簡化這個主題,因此要測試的程式碼已經存在了。

打高爾夫球

要顯示自動測試如何達成我們將檢視一個『真實生活狀態』--讓我們打高爾夫球!我們的商業案例是:我們要開發一個程式持續監視一個高爾夫球場的分數。首先我們戴上物件分析眼鏡馬上我們找到兩個物件:

當一個球員在球場上我們將記錄其分數,我們定義為在此球場高或低於標準桿的打擊數。因此如果頭兩個洞的標準桿是4及5,而球員使用了4及6次打擊數,那麼得分是1(超過標準桿1)

Course類別

Course的名稱是String,而標準桿數是int陣列。除了設定(setter-())及取出(Getter-())方法,我們需要一個方法傳回每一洞的標準桿數『n』:parUpToHole(int n)。下面是這個類別:

package hansen.playground;
import java.util.*;
public class Course {
  private String name; // 球場名稱
  private int[] par; //每一洞的標準桿數
  
  /*
  * 設定球場名稱
  */
  public void setName(String name) {
    this.name = name;
  }
  
  /*
  * 設定球場的標準桿數
  */
  public void setPar(int[] par) {
    this.par = par;
  }

  /*
  *取得球場的洞數
  */
  public int getNumberOfHoles() {
    return par.length;
  }
 
  /*
  *傳回特定洞的標準桿數
  */
  public int parForHole(int hole) {
    return par[hole-1];
  }  
  
  /*
  *傳回第一洞到特定洞的累積標準桿數
  */
  public int parUpToHole(int hole) {
    int sum = 0;
    for (int i = 0; i < hole; i++) { 
      sum += par[i];
    }
    return sum;
  }  
}

為簡化這個範例我們沒有包含檢查習慣錯誤(如在呼叫setPar()之前呼叫getNumberOfHoles()

測試Course類別

使用Course中的方法我們現在可以概略設計一個程式測試『parUpToHole()』是否正常運作。我們假設球場是著名的St. Andrews

Course c = new Course();
	c.setName("St. Andrews");
	int[] par = {4,4,4,4,5,4,4,3,4,4,3,4,4,5,4,4,4,4};
	c.setPar(par);

第一洞及第二洞的標準桿數是4+4=8,因此我們測試如下:

if (c.parUpToHole(2) != 8) {
  System.out.println(
  "*** Error in Course.parUpToHole: par for 2 holes not 8");
}

如果我們執行程式出現錯誤訊息,表示有臭蟲(也可能是測試程式的臭蟲)。若執行結果沒有產生錯誤訊息則表示OK。(譯註1

你可以為所有18洞都寫測試,但多數程式設計師將測試018洞及其中某些洞。如下:

if (c.parUpToHole(0) != 0) {
  System.out.println(
  "*** Error in Course.parUpToHole: par for 0 holes not 0");
}   
if (c.parUpToHole(2) != 8) {
  System.out.println(
  "*** Error in Course.parUpToHole: par for 2 holes not 8");
}   
if (c.parUpToHole(18) != 72) {
  System.out.println(
  "*** Error in Course.parUpToHole: par for 18 holes not 72");
}

這不是撰寫測試程式最精鍊的方式。測試時基本上你想要比對某些值,並依據其結果做某些回應。經由撰寫這些測試程式我們可以自動化parUpToHole()的單元測試,並經由執行測試程式我們可以非常確認這個方法運作正常。

但我們有更好的方式.......

進入JUnit

JUnit是 一個Java開放原始碼專案其中提供單元測試一個非常有用的框架。如果你使用JUnit;上述的程式碼變成如下:

assertEquals(0, c.parUpToHole(0));
	assertEquals(8, c.parUpToHole(2));
	assertEquals(72, c.parUpToHole(18));

這是我們比較喜歡的。同時當錯誤發生時JUnit給我們一個更好的錯誤訊息。舉例來說如果我們改變最後一行的72成為71,JUnit出現的訊息是:

There was 1 failure:

1) testSomething(hansen.playground.TestCourse)
junit.framework.AssertionFailedError: 
	expected:<71> but was:<72>

因此現在我們也自動獲得例外的計算值『c.parUpToHole(18)』。

JUnit提供許多功能,但在下面的例子中我將說明其中較通常且簡單的功能。如果你想進一步瞭解請參考www.junit.org網站裡面有許多文章可供參考。

為了使用JUnit的功能我們應該遵守一些規則如下:

  1. 把你的測試放在一個繼承自JUnit的TestCase類別的類別中。
  2. 如果你的測試案例使用某些通用的資料,以setUp()方法設定資料。
  3. 把測試碼(例如assertEquals())放在一到多個名稱是以test開頭的方法中。

上述三個測試所需的程式碼如下:

package hansen.playground;
	import junit.framework.*;
	
	public class TestCourse extends TestCase { 
	  private Course c;
	    
	  public TestCourse(String name) {
	    super(name);
	  }
	
	  protected void setUp() { 
	    c = new Course();
	    c.setName("St. Andrews");
	    int[] par = {4,4,4,4,5,4,4,3,4,4,3,4,4,5,4,4,4,4};
	    c.setPar(par);         
	  }
	
	  public void testSomething() {
	    assertEquals(0, c.parUpToHole(0));
	    assertEquals(8, c.parUpToHole(2));
	    assertEquals(72, c.parUpToHole(18));
	  }
	    
	}

請注意我們含入(import)JUnit框架--可以從www.junit.org下載。setUp()方法有一個相對應的方法:tearDown()這個方法可以釋放由setUp()所設定的資料。

TestRunner工具

要執行測試程式JUnit提供TestRunner工具。當然那也是以Java所寫成的。這個工具可以作為批次工具;直接在指令列中執行,例如

java junit.textui.TestRunner hansen.playground.TestCourse   

也可以使用AWT或Swing介面:

java junit.awtui.TestRunner hansen.playground.TestCourse 
-- or --
java junit.swingui.TestRunner hansen.playground.TestCourse

它會帶出一個GUI介面:

要執行其他的測試案例只要簡單的輸入類別名稱並按下Run的按鈕。

若要自動測試我們常常執行批次介面,你可以直接從測試程式本身啟動,此時增加一個main()方法:

public static void main(String args[]) {
	    junit.textui.TestRunner.run(TestCourse.class);
	}

assert-方法

有些方法像上面範例中使用的assertEquals()--其中比較重要的如下:

名稱 使用方法
assertEquals(a,b) 評斷兩個參數相等,a與b必須是相同的基礎型別或兩者皆是物件
assertTrue(boolean) 評斷給定的情況是真值
assertNull(Object) 評斷一個物件是空值(null)
assertSame(Object, Object) 評斷兩個物件參考到相同的物件

Round類別

關於Round類別我們需要一個方法--newScore()--以便輸入每洞的桿數。要取得分數我們定義一個方法currentScore()。Round的簡單實作如下:

package hansen.playground;
	import java.util.*;
	
	public class Round {
	  private String player; //球員名稱
	  private Course course; // 球場名稱
	  private int[] score; // 每一洞的得分
	  private int currentHole = 0; // 目前已完成的洞
	  
	  /*
	  *設定球員名稱
	  */
	  public void setPlayer(String name) {
	    player = name;
	  }
	  
	  /*
	  * 設定球場
	  */
	  public void setCourse(Course c) {
	    course = c;
	    score = new int[course.getNumberOfHoles()];
	  }
	  
	  /*
	  * 設定目前球洞的打擊數
	  */
	  public void newScore(int s) {
	    score[currentHole] = s;
	    currentHole++;
	  }
	  
	  /*
	  *取得到目前為止的打擊數 
	  */
	  public int currentStrokes() {
	    int sum = 0;
	    for (int i = 0; i < currentHole; i++) { 
	      sum += score[i];
	    }
	    return sum;
	  }
	  
	  /*
	  *取得目前的得分相對於此球場的標準桿數
	  */
	  public int currentScore() {
	    return currentStrokes() - 
			course.parUpToHole(currentHole);
	  }
	}

測試Round類別

現在我們要為Round製作測試程式。在這個類別中有許多方法要測試。讓我們測試Tiger Woods對抗Thomas Bjorn。

package hansen.playground;
	import junit.framework.*;
	
	public class TestRound extends TestCase { 
	  private Course c;
	  private Round tb,tw;
	    
	  public TestRound(String name) {
	    super(name);
	  }
	
	  protected void setUp() { 
	    c = new Course();
	    c.setName("St. Andrews");
	    int[] par = {4,4,4,4,5,4,4,3,4,4,3,4,4,5,4,4,4,4};
	    c.setPar(par); 
	    tb = new Round();
	    tb.setPlayer("Thomas Bjorn");
	    tb.setCourse(c);
	    tw = new Round();
	    tw.setPlayer("Tiger Woods");
	    tw.setCourse(c); 
	  }
	
	  public static void main(String args[]) {
	    junit.textui.TestRunner.run(TestRound.class);
	  }
	
	  public void testTiger() {
	    // 測試啟始分數是0
	    assertEquals(0, tw.currentStrokes());
	    // 球員獲得一個標準桿
	    tw.newScore(4);
	    assertEquals(4, tw.currentStrokes());
	    assertEquals(0, tw.currentScore());
	    // 球員獲得一個低於標準桿一桿
	    tw.newScore(3);
	    assertEquals(7, tw.currentStrokes());
	    assertEquals(-1, tw.currentScore());
	  }
	    
	  public void testThomas() {
	    // 球員獲得一個高於標準桿一桿
	    tb.newScore(5);
	    assertEquals(5, tb.currentStrokes());
	    assertEquals(1, tb.currentScore());
	    // 球員獲得一個低於標準桿2桿
	    tb.newScore(2);
	    assertEquals(7, tb.currentStrokes());
	    assertEquals(-1, tb.currentScore());
	  }    
	}

注意在這個類別中我們有兩個測試方法。那只是我們決定有多少個方法是比較方便定義。

使用TestRunner批次介面可以得到結果輸出:

	Time: 0,06
	
	OK (2 tests)

『2 tests』表示有兩個『testXXX()』的方法被執行。冒號後面也顯示我們執行2個測試方法的時間。

更多關於TestRunner

當你啟動TestRunner工具時你給它含有你的測試方法的類別名稱。以Java技術稱為『內省(introspection)』TestRunner定址(locates)所有以test為開頭的方法並執行他們。如果你只想測試方法其中的一部份;你必須定義一個靜態方法稱作『系列(或稱作『包』)(suit)』並讓他傳回一個『TestSuit』物件,在TestSuit物件中定義要執行的測試。如果我們要執行『Tiger Woods』的測試,我們定義的『系列』就像下面的程式碼:

public static Test suite() { 
	  TestSuite suite= new TestSuite(); 
	  suite.addTest(new TestRound("testTiger")); 
	  return suite;
	}

要執行兩個測試只要再加上一行:

public static Test suite() { 
	  TestSuite suite= new TestSuite(); 
	  suite.addTest(new TestRound("testTiger")); 
	  suite.addTest(new TestRound("testThomas")); 
	  return suite;
	}

要執行所有的測試只要下列的簡單程式碼:

public static Test suite() { 
	  return new TestSuite(TestRound.class);
	}

當在mian()方法中呼叫TestRunner我們指定的是『系列』而不是類別名稱:

junit.textui.TestRunner.run(suite());

一般習慣是在每一個TestCase類別中定義『系列』方法,因為這樣當你開發你的程式碼時才能選擇你要執行的測試案例。但『系列』也使用於當你想集合你的測試案例成一個測試案例時。

集合你的測試案例

在開發網頁專案(web-project)你將建立更多的測試案例,因為所有的元件可能單獨的執行,但你往往希望多次執行或全部一次執行。只要建立一個『超測試案例(super test case)』就可以很容易的達成;其中你只要建立一個其他測試的『系列』:
 

package hansen.playground;
import junit.framework.*;

public class TestAll extends TestCase {
   
  public TestAll(String name) {
    super(name);
  }

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

  public static Test suite() {
    TestSuite suite = new TestSuite();
    suite.addTest(TestCourse.suite());
    suite.addTest(TestRound.suite());
    return suite;
  }
}

我們在此實際上建立的是一個層級架構(hierarchy)的方式。『TestAll』可以再次使用於其他的測試案例之中,而這些測試案例又可以使用於其他的測試案例中......

經由依據你的應用程式中的功能建立的測試層級架構,你可以方便的在任何層級執行你的測試。

結論

經由使用像JUnit這樣的框架做為單元測試;首先我們可以提升程式設計師的生產力及程式碼的品質。但還是有許多其他的優點。測試案例同時可以作為被測試的程式的文件。因此,在應用程式當中發現的臭蟲,你只要加入一個新的測試以避免這個臭蟲,而隨後--當臭蟲修復--可以證明此臭蟲已被修復。因為測試案例系列應該在應用程式被修改後執行(而且有更多的應用程式將如此作),你可以確認臭蟲無所遁形。

還有一件事要知道的就是使用特定工具或技術的優點。那是你必須自己去嘗試。如果你是Java程式設計師,我建議你嘗試JUnit--或類似的工具。我打賭你將會後悔。

JUnit資源

www.junit.org - JUnit的首頁
junit.sourceforge.net - 文件及文章
www.clarkware.com/articles/JUnitPrimer.html - 一個簡短而準確的介紹JUnit

譯註1:既使程式執行沒有出現錯誤訊息還是有可能有臭蟲,也就是說程式與測試都錯卻是相互抵銷,但此種機率非常小應可忽略。