如何編寫干凈的單元測試用例

——Callback & Template Pattern在單元測試中的應用

 

關鍵詞Callback Function 回調模式 Template Method 模板方法 單元測試

目標讀者:開發工程師

級別:初、中級

 

篇首語

 

       本文假設讀者已經熟悉單元測試及JUnit工具的使用,如果對單元測試及JUnit尚不了解請先學習單元測試及JUnit工具的相關知識。讀者最好對Spring框架及Spring框架提供的單元測試支持有所了解,因為本文案例基于Spring技術編寫。但對Spring不了解并不影響本文所講述的單元測試用例編寫及回調模式、模板方法的應用。

 

       單元測試是編寫高質量代碼的前提,通過編寫有效的單元測試即可以保證代碼的質量又可以提高開發速度,因為大多數問題都可以通過單元測試發現并解決而不需要部署到應用服務器。縱覽網上流行的優秀開源框架,無一不提供完整的單元測試用例。Spring框架便是其中的代表和佼佼者,因為Spring所遵循的控制反轉(IoC)和依賴注入(DI)原則使編寫有效、干凈的單元測試用例變得更加方便、快捷。

 

編寫單元測試用例

 

       本文所采用的案例非常簡單,就是對數據庫表的增、刪、改、查操作進行測試。假設我們有這樣一個表urlMySql數據庫):

字段

類型

描述

id

int

主鍵,自增類型

url

varchar

網站地址,唯一不能重復

email

varchar

Email地址

name

varchar

名稱

正如你所見,該表只有幾個字段,但對于我們的案例來說完全夠用。

 

       看到此處,你應該清楚我們是要對數據庫操作進行單元測試。如果你是一位經驗豐富的開發人員,此時已經會有許多疑問,甚至已經失去繼續閱讀本文的興趣:

²        單元測試不應該直接操作數據庫?

²        對數據庫操作的單元測試可以采用DAO模式,Mock一個實現類?

²        使用內存數據庫?

²        其他?

 

我必須在這里告訴你,或許本文所采用的案例有些不恰當,但并不影響本文主題。而且,本文之所以采用數據庫操作作為案例也有特殊用意,所以請繼續你的旅程:)

 

 

數據庫表有了,我們接下來編寫DAO及其實現類:

 

DAO接口:

/**
* @author tao.youzt
*/
public interface BizUrlDAO {
public Object insert(BizUrlDO bizUrlDO);
public int delete(String url);
public BizUrlDO getByUrl(String url);
}

 

DAO實現類,該類繼承一個支持類,封裝了對數據庫的操作。

/**
* @author tao.youzt
*/
public class BizUrlIbatisImpl extends GodzillaDaoSupport implements BizUrlDAO {
private static final String GET_BY_URL = "SELECT-BIZ-URL";
private static final String DELETE     = "DELETE-BIZ-URL";
private static final String INSERT     = "INSERT-BIZ-URL";
public int delete(String url) {
return this.delete(DELETE, url);
}
public BizUrlDO getByUrl(String url) {
return this.queryForObject(GET_BY_URL, url, BizUrlDO.class);
}
public Object insert(BizUrlDO bizUrlDO) {
return this.insert(INSERT, bizUrlDO);
}
}

DO領域對象

/**
* @author tao.youzt
*/
public class BizUrlDO {
private int    id;
private String url;
private String email;
private String name;
// getter and setter
}

因為本文案例使用Spring作為底層框架,因此這里需要編寫Spring配置文件對DAO進行組裝。

Godzilla-dao.xml

 

Godzilla-db.xml

 DAO及其配置文件都已經準備完畢,我們接下來編寫測試用例。Spring為單元測試提供了很多有用的支持類,我們在這里使用的是:

org.springframework.test.AbstractDependencyInjectionSpringContextTests

該類提供了POJO屬性自動注入的能力,只要為為你的屬性字段提供一個Set方法即可。下面我們來看完整的測試用例:

/**
* @author tao.youzt
*/
public class TestBizUrlDAO extends AbstractDependencyInjectionSpringContextTests {
private BizUrlDAO bizUrlDAO;
@Override
protected String[] getConfigLocations() {
return new String[]{"godzilla-dao.xml","godzilla-db.xml"};
}
public void testInsert(){
bizUrlDAO.insert(generateDO());
assertNotNull(bizUrlDAO.getByUrl("www.easyjf.com"));
}
public void testDuplicateInsert(){
bizUrlDAO.insert(generateDO());
try{
bizUrlDAO.insert(generateDO());
assertFalse("Must throw an exception!",true);
}catch(Exception e){
assertTrue(true);
}
}
public void testDelete(){
bizUrlDAO.insert(generateDO());
assertNotNull(bizUrlDAO.getByUrl("www.easyjf.com"));
bizUrlDAO.delete("www.easyjf.com");
assertNull(bizUrlDAO.getByUrl("www.easyjf.com"));
}
private BizUrlSynchronizeDO generateDO() {
BizUrlDO bizUrlDO = new BizUrlDO();
bizUrlDO.setUrl("www.easyjf.com");
bizUrlDO.setName("EasyJWeb");
bizUrlDO.setEmail("webmaster@easyjf.com");
return bizUrlDO;
}
public void setBizUrlDAO(BizUrlSynchronzieDAO bizUrlDAO) {
this.bizUrlDAO = bizUrlDAO;
}
}

getConfigLocations()方法為AbstractDependencyInjectionSpringContextTests 提供配置,Spring會根據該配置文件自動注入bizUrlDAO屬性。testInsert()方法用于測試插入新數據,注意這里有個問題,如果數據庫中已經存在該URL的記錄,則應用會報錯,所以這里還要進行數據清除準備處理,我們稱之為“測試環境準備”,以后會用到該名詞;testDuplicateInsert()方法用于測試插入重復數據的情況,該方法同樣存在上面的問題;testDelete()方法用于測試刪除數據的情況,這里盡管準備了數據,但仍沒有考慮數據庫中已經有記錄的情況。

 

    綜上所述,盡管該測試類已經比較清晰,但仍然存在許多不足之處。我們將在后面的章節進行詳細分析,并給出解決方案。

 

Callback Function & Template Method  Pattern

 

       回調函數(Callback Function)和模板方法(Template Method)是軟件架構設計中最常用的兩種設計模式,這兩種設計模式在Spring框架中隨處可見。

 

       關于本節是否要詳細介紹回調函數(Callback Function)和模板方法(Template Method)模式的問題,筆者考慮了很長時間。因為網絡上對這兩種普遍使用的設計模式的定義層出不窮,各有各的道理,很難說誰是誰非。況且,針對不同的應用場景,這兩種模式也有許多變體,或者聯合使用。

 

因此,筆者最終決定不在此處對這兩種模式做任何定義或引用,請讀者自行參閱相關文檔資料。

 

回調函數和模板方法模式在單元測試中的應用

 

上一節我們簡單的回顧了回調函數和模板方法模式,Spring框架中大量采用了這兩種設計模式,有興趣的讀者可以閱讀Spring框架代碼進一步鞏固對這兩種模式的理解和運用。本節將結合回調函數模式和模板方法模式對前面的測試用例進行重構,讀者可以在重構過程中逐步了解這兩種設計模式的運用。

 

首先,讓我們簡單總結一下前面測試用例的問題:

 

一、             抽象層次太低,不夠通用?

例如,對于getConfigLocations()方法,我們完全可以放到一個父類中實現,因為對于一個項目而言,其配置文件大多都是統一的,沒有必要在沒有測試類中都定義該方法。

/**
* DAL層測試支持類.
*
*
* 除非特殊情況,所有DAO都要繼承此類.
*
* @author tao.youzt
*/
public abstract class GodzillaDalTestSupport extends AbstractDependencyInjectionSpringContextTests {
/*
* @see org.springframework.test.AbstractDependencyInjectionSpringContextTests#getConfigLocations()
*/
@Override
protected final String[] getConfigLocations() {
String[] configLocations = null;
String[] customConfigLocations = getCustomConfigLocations();
if (customConfigLocations != null && customConfigLocations.length > 0) {
configLocations = new String[customConfigLocations.length + 2];
configLocations[0] = "classpath:godzilla/dal/godzilla-db-test.xml";
configLocations[1] = "classpath:godzilla/dal/godzilla-dao.xml";
for (int i = 2; i < configLocations.length; i++) {
configLocations[i] = customConfigLocations[i - 2];
}
return configLocations;
} else {
return new String[] { "classpath:godzilla/dal/godzilla-db-test.xml",
"classpath:godzilla/dal/godzilla-dao.xml" };
}
}
/**
* 子類可以覆蓋該方法加載個性化配置.
*
* @return
*/
protected String[] getCustomConfigLocations() {
return null;
}
}

如圖所示,我們提煉了一個抽象支持類,實現了getConfigLocations()方法,同時還提供了getCustomConfigLocations()方法供子類使用,子類可以通過重載該方法提供定制的配置。

 

有了該支持類,具體測試類只需要繼承該類并編寫測試邏輯即可。

二、             缺少準備測試環境和清除測試數據的環節?

對于大多數測試用例,可能都會涉及到初始化數據和清除測試數據的問題,最典型的就是數據庫操作,這也是本文采用數據庫操作作為案例的原因。那么如何實現呢?很顯然在每個測試方法中都編寫準備環境和清除測試數據的代碼是不合適的,因為大多數時候對于一個測試類而言,準備環境和清除數據的邏輯都是一樣的。聰明的你一定會想到定義兩個方法,一個初始化環境,一個清除測試數據。是的,就是這樣!

/**
* @author tao.youzt
*/
public class TestBizUrlDAO extends AbstractDependencyInjectionSpringContextTests {
private BizUrlDAO bizUrlDAO;
@Override
protected String[] getConfigLocations() {
return new String[]{"godzilla-dao.xml","godzilla-db.xml"};
}
protected void setupEnv(){
bizUrlDAO.delete("www.easyjf.com");
}
protected void cleanEnv(){
bizUrlDAO.delete("www.easyjf.com");
}
public void testTemp(){
setupEnv();
bizUrlDAO.insert(generateDO());
assertNotNull(bizUrlDAO.getByUrl("www.easyjf.com"));
setupEnv();
}
}

如你所見,我們在這里定義了setupEnv()cleanEnv()兩個方法,分別用于初始化環境和清除測試數據,然后在測試方法開始和結束時分別調用這兩個方法。這的確達到了我們的目的,不用在每個測試方法中都編寫初始化和清除邏輯!但此時你一定發現在每個測試方法前后都調用setupEnv()cleanEnv()也很不爽,那說明我們的抽象程度還不夠!那么該如何做的更好呢?

 

這里該到模板方法(Template Method)模式發揮威力的時候了。我們將使用模板方法來繼續重構前面的案例。讓我們先來定義一個方法:

/**
* @author tao.youzt
*/
public class TestBizUrlDAO extends AbstractDependencyInjectionSpringContextTests {
private BizUrlDAO bizUrlDAO;
@Override
protected String[] getConfigLocations() {
return new String[]{"godzilla-dao.xml","godzilla-db.xml"};
}
protected void setupEnv(){
bizUrlDAO.delete("www.easyjf.com");
}
protected void cleanEnv(){
bizUrlDAO.delete("www.easyjf.com");
}
public void testTemp(){
//do test logic in this method
execute();
}
protected void execute(){
setupEnv();
doTestLogic();
setupEnv();
}
}

相比之前的方法,我們這里已經有了一些進步,定義了一個execute方法,在該方法開始和結束分別執行初始化和清除邏輯,然后由doTestLogic()方法實現測試邏輯。實際測試方法中只要執行execute方法,并傳入測試邏輯就可以了。瞧,不經意間我們已經實現了模板方法模式——把通用的邏輯封轉起來,變化的部分由具體方法提供。怎么,不相信么?呵呵,設計模式其實并不復雜,就是前人解決通用問題的一些最佳實踐總結而已。

 

此時你可能會說,TeseCase類已經提供了setUp()tearDown()方法來做這件事情,我也想到了,哈哈!但這并不和本文產生沖突!

 

問題似乎越來越清晰,但我們遭遇了一條無法跨越的鴻溝——如何才能把測試邏輯傳遞到execute方法中呢?單靠傳統的編程方法已經無法解決這個問題,因此我們必須尋找其他途徑。

 

可能此時此刻你已經想到,本文另一個重要概念——回調方法模式還沒有用到,是不是該使用該模式了?沒錯,就是它了!我先把代碼給出,然后再詳細解釋。

 

我們提供了一個抽象類TestExecutor,并定義一個抽象的execute方法,然后為測試類的execute方法傳入一個TestExecutor的實例,并調用該實例的execute方法。最后,我們的測試方法中只需要new一個TestExecutor,并在execute方法中實現測試邏輯,便可以按照預期的方式執行:準備測試環境-執行測試邏輯-清除測試數據。這便是一個典型的回調方法模式的應用!

 

模板方法和回調函數模式說起來挺懸,其實也就這么簡單,明白了吧:)

三、             如何為每個測試方法單獨提供環境方法呢?

通過前面的講解,相信大家對模板方法和回調函數模式都已經掌握了,這里直接給出相關代碼:

/**
* DAL層測試支持類.
*
*
* 除非特殊情況,所有DAO都要繼承此類.
*
* @author tao.youzt
*/
public abstract class GodzillaDalTestSupport extends AbstractDependencyInjectionSpringContextTests {
/*
* @see org.springframework.test.AbstractDependencyInjectionSpringContextTests#getConfigLocations()
*/
@Override
protected final String[] getConfigLocations() {
String[] configLocations = null;
String[] customConfigLocations = getCustomConfigLocations();
if (customConfigLocations != null && customConfigLocations.length > 0) {
configLocations = new String[customConfigLocations.length + 2];
configLocations[0] = "classpath:godzilla/dal/godzilla-db-test.xml";
configLocations[1] = "classpath:godzilla/dal/godzilla-dao.xml";
for (int i = 2; i < configLocations.length; i++) {
configLocations[i] = customConfigLocations[i - 2];
}
return configLocations;
} else {
return new String[] { "classpath:godzilla/dal/godzilla-db-test.xml",
"classpath:godzilla/dal/godzilla-dao.xml" };
}
}
/**
* 子類可以覆蓋該方法加載個性化配置.
*
* @return
*/
protected String[] getCustomConfigLocations() {
return null;
}
/**
* 準備測試環境.
*/
protected void setupEnv() {
}
/**
* 清除測試數據.
*/
protected void cleanEvn() {
}
/**
* 測試用例執行器.
*/
protected abstract class TestExecutor {
/**
* 準備測試環境
*/
public void setupEnv() {
}
/**
* 執行測試用例.
*/
public abstract void execute();
/**
* 清除測試數據.
*/
public void cleanEnv() {
}
}
/**
* 執行一個測試用例.
*
* @param executor
*/
protected final void execute(final TestExecutor executor) {
execute(IgnoralType.NONE, executor);
}
/**
* 執行一個測試用例.
*
* @param executor
*/
protected final void execute(final IgnoralType ignoral, final TestExecutor executor) {
switch (ignoral) {
case NONE: {
setupEnv();
executor.setupEnv();
executor.execute();
executor.cleanEnv();
cleanEvn();
break;
}
case BOTH: {
executor.execute();
break;
}
case GLOBAL: {
executor.setupEnv();
executor.execute();
executor.cleanEnv();
break;
}
case LOCAL: {
setupEnv();
executor.execute();
cleanEvn();
break;
}
case GLOBAL_S: {
executor.setupEnv();
executor.execute();
executor.cleanEnv();
cleanEvn();
break;
}
case GLOBAL_C: {
setupEnv();
executor.setupEnv();
executor.execute();
executor.cleanEnv();
break;
}
case LOCAL_S: {
setupEnv();
executor.execute();
executor.cleanEnv();
cleanEvn();
break;
}
case LOCAL_C: {
setupEnv();
executor.setupEnv();
executor.execute();
cleanEvn();
break;
}
case BOTH_SETUP: {
executor.execute();
executor.cleanEnv();
cleanEvn();
break;
}
case BOTH_CLEAN: {
setupEnv();
executor.setupEnv();
executor.execute();
break;
}
case GLOBAL_S_LOCAL_C: {
executor.setupEnv();
executor.execute();
cleanEvn();
break;
}
case GLOBAL_C_LOCAL_S: {
setupEnv();
executor.execute();
executor.cleanEnv();
break;
}
}
}
/**
* 忽略類型Enum.
*/
public enum IgnoralType {
/** 不忽略任何環境相關方法 */
NONE,
/** 忽略全局環境相關方法 */
GLOBAL,
/** 忽略局部環境相關方法 */
LOCAL,
/** 忽略所有環境相關方法 */
BOTH,
/** 忽略全局準備測試環境方法 */
GLOBAL_S,
/** 忽略全局清除測試數據方法 */
GLOBAL_C,
/** 忽略局部準備測試環境方法 */
LOCAL_S,
/** 忽略局部清除測試數據方法 */
LOCAL_C,
/** 忽略全部準備測試環境方法 */
BOTH_SETUP,
/** 忽略全部清楚測試數據方法 */
BOTH_CLEAN,
/** 忽略全局準備測試環境和局部清除測試數據方法 */
GLOBAL_S_LOCAL_C,
/** 忽略全局清除測試數據和局部準備測試環境方法 */
GLOBAL_C_LOCAL_S
}
}

/**
* URL DAO測試類.
*
* @author tao.youzt
*/
public class TestBizUrlDAO extends GodzillaDalTestSupport {
private BizUrlDAO bizUrlDAO;
@Override
protected void setupEnv() {
bizUrlDAO.delete("www.easyjf.com");
}
@Override
protected void cleanEvn() {
bizUrlDAO.delete("www.easyjf.com");
}
/**
* 測試插入一條新數據.
*/
public void testInsert() {
execute(new TestExecutor() {
@Override
public void execute() {
bizUrlDAO.insert(generateDO());
assertNotNull(bizUrlDAO.getByUrl("www.easyjf.com"));
}
});
}
/**
* 測試重復插入數據的情況.
*/
public void testDuplicateInsert() {
execute(new TestExecutor() {
@Override
public void setupEnv() {
bizUrlDAO.insert(generateDO());
}
@Override
public void execute() {
try {
bizUrlDAO.insert(generateDO());
assertTrue("Must throw an exception!", false);
} catch (Exception e) {
assertTrue("Expect this exception.", true);
}
}
});
}
/**
* 測試刪除一條已經存在的數據.
*/
public void testDelete() {
execute(IgnoralType.GLOBAL_C, new TestExecutor() {
@Override
public void execute() {
assertNotNull(bizUrlDAO.getByUrl("www.easyjf.com"));
bizUrlDAO.delete("www.easyjf.com");
assertNull(bizUrlDAO.getByUrl("www.easyjf.com"));
}
@Override
public void setupEnv() {
bizUrlDAO.insert(generateDO());
}
});
}
/**
* 生成一個用于測試的DO.
*
* @return
*/
private BizUrlSynchronizeDO generateDO() {
BizUrlDO bizUrlDO = new BizUrlDO();
bizUrlDO.setUrl("www.easyjf.com");
bizUrlDO.setName("EasyJWeb");
bizUrlDO.setEmail("webmaster@easyjf.com");
return bizUrlDO;
}
public void setBizUrlDAO(BizUrlSynchronzieDAO bizUrlDAO) {
this.bizUrlDAO = bizUrlDAO;
}
}

              注意testDeleate()方法,我們傳入了兩個參數,第一個參數IgnoralType.GLOBAL_C 代表忽略哪個方法,有12種類型可以設置。GLOBAL_C代表忽略全局的清除測試數據方法,其他見代碼注釋。

 

結束語

 

       本文以單元測試為環境,講解了模板方法和回調函數模式的應用,但不局限于單元測試環境,讀者可以在理解和掌握的基礎上在任何程序中應用這些模式。由于筆者能力有限,文中難免存在錯誤之處,誠摯歡迎大家的批評和意見。