用Junit Framework編寫單元測試

用Junit Framework編寫單元測試
申文波(來自IBM DW中國)  2002年12月01日

  隨着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和企業應用。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章