用Junit Framework編寫單元測試

magus_yang發表於2004-09-01

轉載自:http://www.sawin.com.cn/doc/QM/Test/junitframework.htm


用Junit Framework編寫單元測試 

申文波

本文來自IBM DW中國  

隨著Refactoring技術和XP軟體工程技術的廣泛推廣,單元測試的作用在軟體工程中變得越來越重要,而一個簡明易學、適用廣泛、高效穩定的單元測試框架則對成功的實施單元測試有著至關重要的作用。在java程式設計語句環境裡,Junit Framework是一個已經被多數java程式設計師採用和實證的優秀的測試框架,但是多數沒有嘗試Junit Framework的程式設計師在學習如何Junit Framework來編寫適應自己開發專案的單元測試時,依然覺得有一定的難度,這可能是因為Junit隨框架程式碼和實用工具附帶的使用者指南和文件的著重點在於解釋單元測試框架的設計方法以及簡單的類使用說明,而對在特定的測試框架(Junit)下如何實施單元測試,如何在專案開發的過程中更新和維護已經存在的單元測試程式碼沒有詳細的解釋。因此本文件就兩個著重點對Junit所附帶的文件進行進一步的補充和說明,使Junit能被更多的開發團隊採用,讓單元測試乃至Refactoring、XP技術更好在更多的開發團隊中推廣。


單元測試的編寫原則

  Junit附帶文件所列舉的單元測試帶有一定的迷惑性,因為幾乎所有的示例單元都是針對某個物件的某個方法,似乎Junit的單元測試僅適用於類組織結構的靜態約束,從而使初學者懷疑Junit下的單元測試所能帶來的效果。因此我們需要重新定義如何確定有價值的單元測試以及如何編寫這些單元測試、維護這些單元測試,從而讓更多的程式設計師接受並熟悉Junit下的單元測試的編寫。
  在Junit單元測試框架的設計時,作者一共設定了三個總體目標:
  第一個是簡化測試的編寫,這種簡化包括測試框架的學習和實際測試單元的編寫;
  第二個是使測試單元保持永續性;
  第三個則是可以利用既有的測試來編寫相關的測試。
  從這三個目標可以看出,單元測試框架的基本設計考慮依然是從我們現有的測試方式和方法出發,而只是使測試變得更加容易實施和擴充套件並保持永續性。因此編寫單元測試的原則可以從我們通常使用的測試方法借鑑和利用。


如何確定單元測試


  在我們通常的測試中,一個單元測試一般針對於特定物件的一個特定特性,譬如,假定我們編寫了一個針對特定資料庫訪問的連線池的類包實現,我們會建立以下的單元測試:
  · 在連線池啟動後,是否根據定義的規則在池中建立了相應數量的資料庫連線;
  · 申請一個資料庫連線,是否根據定義的規則從池中直接獲得快取連線的引用,還是建立新的連線;
  · 釋放一個資料庫連線後,連線是否根據定義的規則被池釋放或者快取以便以後使用;
  · 後臺Housekeeping執行緒是否按照定義的規則釋放已經過期的連線申請;
  · 如果連線有時間期限,後臺Housekeeping執行緒是否定期釋放已經過期的快取連線;
  這兒只列出了部分的可能測試,但是從這個列表我們可以看出單元測試的粒度。一個單元測試基本是以一個物件的明確特性為基礎,單元測試的過程應該限定在一個明確的執行緒範圍內。根據上面所述,一個單元測試的測試過程非常類似於一個Use Case的定義,但是單元測試的粒度一般來說比Use Case的定義要小,這點是容易理解的,因為Use Case是以單獨的事務單元為基礎的,而單元測試是以一組聚合性很強的物件的特定特徵為基礎的,一般而言一個事務中會利用許多的系統特徵來完成具體的軟體需求。
  從上面的分析我們可以得出,測試單元應該以一個物件的內部狀態的轉換為基本編寫單元。一個軟體系統就和一輛設計好的汽車一樣,系統的狀態是由同一時刻時系統內部的各個分立的部件的狀態決定的,因此為了確定一個系統最終的行為符合我們起始的要求,我們首先需要保證系統內的各個部分的狀態會符合我們的設計要求,所以我們的測試單元的重點應該放在確定物件的狀態變換上。
  然而需要注意的並不是所有的物件組特徵都需要被編寫成獨立的測試單元,如何在物件組特徵裡篩選有價值的測試單元的原則在JUnitTest Infected: Programmers Love Writing Tests一文中得到了正確的描述,你應該在有可能引入錯誤的地方引入測試單元,通常這些地方存在於有特定邊界條件、複雜演算法以及需求變動比較頻繁的程式碼邏輯中。除了這些特性需要被編寫成獨立的測試單元外,還有一些邊界條件比較複雜的物件方法也應該被編寫成獨立的測試單元,這部分單元測試已經在Junit文件中被較好的描述和解釋過了。
  在基本確定了需要編寫的單元測試,我們還應該問自己:編寫好了這些測試,我們是否可以有把握地告訴自己,如果程式碼通過了這些單元測試,我們能認定程式的執行是正確的,符合需求的。如果我們不能非常的確定,就應該看看是否還有遺漏的需要編寫的單元測試或者重新審視我們對軟體需求的理解。通常來說,在開始使用單元測試的時候,更多的單元測試總是沒有錯的。
  一旦我們確定了需要被編寫的測試單元,接下來就應該:


如何編寫單元測試


  在XP下強調單元測試必須由類包的編寫者負責編寫,這個限定對於我們設定的測試目標是必須的。因為只有這樣,測試才能保證物件的執行時態行為符合需求,而僅通過類介面的測試,我們只能確保物件符合靜態約束,因此這就要求我們在測試的過程中,必須開放一定的內部資料結構,或者針對特定的執行行為建立適當的資料記錄,並把這些資料暴露給特定的測試單元。這也就是說我們在編寫單元測試時必須對相應的類包進行修改,這樣的修改也發生在我們以前使用的測試方法中,因此以前的測試標記及其他一些測試技巧仍然可以在Junit測試中改進使用。
  由於單元測試的總體目標是負責我們的軟體在執行過程中的正確無誤,因此在我們對一個物件編寫單元測試的時候,我們不但需要保證類的靜態約束符合我們的設計意圖,而且需要保證物件在特定的條件下的執行狀態符合我們的預先設定。還是拿資料庫緩衝池的例子說明,一個緩衝池暴露給其他物件的是一組使用介面,其中包括對池的引數設定、池的初始化、池的銷燬、從這個池裡獲得一個資料連線以及釋放連線到池中,對其他物件而言隨著各種條件的觸發而引起池的內部狀態的變化是不需要知道的,這一點也是符合封裝原理的。但是池物件的狀態變化,譬如:快取的連線數在某些條件下會增長,一個連線在足夠長的執行後需要被徹底釋放從而使池的連線被更新等等,雖然外部物件不需要明確,但是卻是程式執行正確的保證,所以我們的單元測試必須保證這些內部邏輯被正確的執行。
  編譯語言的測試和除錯是很難對執行的邏輯過程進行跟蹤的,但是我們知道,無論邏輯怎麼執行,如果狀態的轉換符合我們的行為設定,那驗證結果顯然是正確的,因此在對一個物件進行單元測試的時候,我們需要對多數的狀態轉換進行分析和對照,從而驗證物件的行為。狀態是通過一系列的狀態資料來描述的,因此編寫單元測試首先分析出狀態的變化過程(狀態轉換圖對這個過程的描述非常清晰),然後根據狀態的定義確定分析的狀態資料,最後是提供這些內部的狀態資料的訪問。在資料庫連線池的例子中,我們對池實現的物件DefaultConnectionProxy的狀態變換進行分析後,我們決定把表徵狀態的OracleConnectionCacheImpl物件公開給測試類。參見示例一

示例一
/**
* 這個類簡單的包裝了oracle對資料連線緩衝池的實現。
*
*/
public class DefaultConnectionProxy extends ConnectionProxy {
private static final String name = "Default Connection Proxy";
private static final String description =
"這個類簡單的包裝了oracle對資料連線緩衝池的實現。";
private static final String author = "Ion-Global.com";
private static final int major_version = 0;
private static final int minor_version = 9;
private static final boolean pooled = true;
private ConnectionBroker connectionBroker = null;
private Properties props;
private Properties propDescriptions;
private Object initLock = new Object();
// Test Code Begin...
/*
為了能夠了解物件的狀態變化,因此需要把表徵物件內部狀態變化的部分私有變數提供公共的訪問介面
(或者提供讓同一個類包的訪問介面),以便使測試單元可以有效地判斷物件的狀態轉變,
在本示例中對包裝的OracleConnectionCacheImpl物件提供訪問介面。
*/
OracleConnectionCacheImpl getConnectionCache() {
if (connectionBroker == null) {
throw new IllegalStateException("You need start the server
first.");
}

return connectionBroker.getConnectionCache();
}
// Test Code End...
  在公開內部狀態資料後,我們就可以編寫我們的測試單元了,單元測試的選擇方法和選擇尺度已經在本文前面章節進行了說明。但是仍然需要注意的是,由於assert方法會丟擲一個error,你應該在測試方法的最後集中用assert相關方法進行判斷,這樣可以確保資源得到釋放。   

對資料庫連線池的例子,我們可以建立測試類DefaultConnectionProxyTest,同時建立數個test case,如下:

示例二
/**
* 這個類對示例一中的類進行簡單的測試。
*
*/
public class DefaultConnectionProxyTest extends TestCase {
private DefaultConnectionProxy conProxy = null;
private OracleConnectionCacheImpl cacheImpl = null;
private Connection con = null;
/** 設定測試的fixture,建立必要的測試起始環境。
*/
protected void setUp() {
conProxy = new DefaultConnectionProxy();
conProxy.start();
cacheImpl = conProxy.getConnectionCache();
}
/** 對示例一中的物件進行服務啟動後的狀態測試,檢查是否在服務啟動後,
連線池的引數設定是否正確。
*/
public void testConnectionProxyStart() {
int minConnections = 0;
int maxConnections = 0;
assertNotNull(cacheImpl);
try {
minConnections =
Integer.parseInt(PropertyManager.getProperty
("DefaultConnectionProxy.minConnections"));
maxConnections =
Integer.parseInt(PropertyManager.getProperty
("DefaultConnectionProxy.maxConnections"));
} catch (Exception e) {
// ignore the exception
}
assertEquals(cacheImpl.getMinLimit(), minConnections);
assertEquals(cacheImpl.getMaxLimit(), maxConnections);
assertEquals(cacheImpl.getCacheSize(), minConnections);
}
/** 對示例一中的物件進行獲取資料庫連線的測試,看看是否可以獲取有效的資料庫連線,
並且看看獲取連線後,連線池的狀態是否按照既定的策略進行變化。由於assert方法丟擲的是
error物件,因此儘可能把assert方法放置到方法的最後集體進行測試,這樣在方法內開啟的
資源,才能有效的被正確關閉。
*/
public void testGetConnection() {
int cacheSize = cacheImpl.getCacheSize();
int activeSize = cacheImpl.getActiveSize();
int cacheSizeAfter = 0;
int activeSizeAfter = 0;
con = conProxy.getConnection();
if (con != null) {
activeSizeAfter = cacheImpl.getActiveSize();
cacheSizeAfter = cacheImpl.getCacheSize();
try {
con.close();
} catch (SQLException e) {
}
} else {
assertNotNull(con);
}
/*如果連線池中的實際使用連線數小於快取連線數,檢查獲取的新的資料連線是否
從快取中獲取,反之連線池是否建立新的連線
*/
if (cacheSize > activeSize) {
assertEquals(activeSize + 1, activeSizeAfter);
assertEquals(cacheSize, cacheSizeAfter);
} else {
assertEquals(activeSize + 1, cacheSizeAfter);
}
}
/** 對示例一中的物件進行資料庫連線釋放的測試,看看連線釋放後,連線池的
狀態是否按照既定的策略進行變化。由於assert方法丟擲的是error物件,因此儘可
能把assert方法放置到方法的最後集體進行測試,這樣在方法內開啟的
資源,才能有效的被正確關閉。
*/
public void testConnectionClose() {
int minConnections = cacheImpl.getMinLimit();
int cacheSize = 0;
int activeSize = 0;
int cacheSizeAfter = 0;
int activeSizeAfter = 0;
con = conProxy.getConnection();
if (con != null) {
cacheSize = cacheImpl.getCacheSize();
activeSize = cacheImpl.getActiveSize();
try {
con.close();
} catch (SQLException e) {
}
activeSizeAfter = cacheImpl.getActiveSize();
cacheSizeAfter = cacheImpl.getCacheSize();
} else {
assertNotNull(con);
}
assertEquals(activeSize, activeSizeAfter + 1);
/*如果連線池中的快取連線數大於最少快取連線數,檢查釋放資料連線後是否快取連線數比之前減少了一個,反之快取連線數是否保持為最少快取連線數
*/
if (cacheSize > minConnections) {
assertEquals(cacheSize, cacheSizeAfter + 1);
} else {
assertEquals(cacheSize, minConnections);
}
}
/** 釋放建立測試起始環境時的資源。
*/
protected void tearDown() {
cacheImpl = null;
conProxy.destroy();
}
public DefaultConnectionProxyTest(String name) {
super(name);
}
/** 你可以簡單的執行這個類從而對類中所包含的測試單元進行測試。
*/
public static void main(String args[]) {
junit.textui.TestRunner.run(DefaultConnectionProxyTest.class);
}
}
  當單元測試完成後,我們可以用Junit提供的TestSuite物件對測試單元進行組織,你可以決定測試的順序,然後執行你的測試。


如何維護單元測試

通過上面的描述,我們對如何確定和編寫測試有了基本的瞭解,但是需求總是變化的,因此我們的單元測試也會根據需求的變化不斷的演變。如果我們決定修改類的行為規則,可以明確的是,我們當然會對針對這個類的測試單元進行修改,以適應變化。但是如果對這個類僅有呼叫關係的類的行為定義沒有變化則相應的單元測試仍然是可靠和充分的,同時如果包含行為變化的類的物件的狀態定義與其沒有直接的關係,測試單元仍然起效。這種結果也是封裝原則的優勢體現。


關於作者

  申文波:1973年出生,現於艾昂科技上海公司任資深技術顧問。在關聯式資料庫物件建模方面有較長的工作經驗,熟悉Java語言,目前從事的工作領域主要包括OOA、OOD和企業應用。

相關文章