篇首語

 

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

 

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

 編寫單元測試用例

 

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

字段

 

 

類型

 

 

描述

 

 

id

int

主鍵,自增類型

url

varchar

網(wǎng)站地址,唯一不能重復

email

varchar

Email地址

name

varchar

名稱

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

 

 

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

²        單元測試不應該直接操作數(shù)據(jù)庫?

²        對數(shù)據(jù)庫操作的單元測試可以采用DAO模式,Mock一個實現(xiàn)類?

²        使用內(nèi)存數(shù)據(jù)庫?

²        其他?

 

 


數(shù)據(jù)庫表有了,我們接下來編寫DAO及其實現(xiàn)類:

 

 

DAO接口:

 

 

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

 

 

DAO實現(xiàn)類,該類繼承一個支持類,封裝了對數(shù)據(jù)庫的操作。

/**
* @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領域?qū)ο?/span>

 

 

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

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

DAO及其配置文件都已經(jīng)準備完畢,我們接下來編寫測試用例。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會根據(jù)該配置文件自動注入bizUrlDAO屬性。testInsert()方法用于測試插入新數(shù)據(jù),注意這里有個問題,如果數(shù)據(jù)庫中已經(jīng)存在該URL的記錄,則應用會報錯,所以這里還要進行數(shù)據(jù)清除準備處理,我們稱之為“測試環(huán)境準備”,以后會用到該名詞;testDuplicateInsert()方法用于測試插入重復數(shù)據(jù)的情況,該方法同樣存在上面的問題;testDelete()方法用于測試刪除數(shù)據(jù)的情況,這里盡管準備了數(shù)據(jù),但仍沒有考慮數(shù)據(jù)庫中已經(jīng)有記錄的情況。

 

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

 

Callback Function & Template Method  Pattern

 

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

 

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

 

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

 

回調(diào)函數(shù)和模板方法模式在單元測試中的應用

 

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

 

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

 

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

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

/**
* 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;
}
}

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

 

 

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

二、             缺少準備測試環(huán)境和清除測試數(shù)據(jù)的環(huán)節(jié)?

 

 

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

/**
* @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()兩個方法,分別用于初始化環(huán)境和清除測試數(shù)據(jù),然后在測試方法開始和結(jié)束時分別調(diào)用這兩個方法。這的確達到了我們的目的,不用在每個測試方法中都編寫初始化和清除邏輯!但此時你一定發(fā)現(xiàn)在每個測試方法前后都調(diào)用setupEnv()cleanEnv()也很不爽,那說明我們的抽象程度還不夠!那么該如何做的更好呢?

 

 

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

/**
* @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();
}
}

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

 

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

 

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

 

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

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

 

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

三、             如何為每個測試方法單獨提供環(huán)境方法呢?  

 

通過前面的講解,相信大家對模板方法和回調(diào)函數(shù)模式都已經(jīng)掌握了,這里直接給出相關代碼:

/**
* 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;
}
/**
* 準備測試環(huán)境.
*/
protected void setupEnv() {
}
/**
* 清除測試數(shù)據(jù).
*/
protected void cleanEvn() {
}
/**
* 測試用例執(zhí)行器.
*/
protected abstract class TestExecutor {
/**
* 準備測試環(huán)境
*/
public void setupEnv() {
}
/**
* 執(zhí)行測試用例.
*/
public abstract void execute();
/**
* 清除測試數(shù)據(jù).
*/
public void cleanEnv() {
}
}
/**
* 執(zhí)行一個測試用例.
*
* @param executor
*/
protected final void execute(final TestExecutor executor) {
execute(IgnoralType.NONE, executor);
}
/**
* 執(zhí)行一個測試用例.
*
* @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 {
/** 不忽略任何環(huán)境相關方法 */
NONE,
/** 忽略全局環(huán)境相關方法 */
GLOBAL,
/** 忽略局部環(huán)境相關方法 */
LOCAL,
/** 忽略所有環(huán)境相關方法 */
BOTH,
/** 忽略全局準備測試環(huán)境方法 */
GLOBAL_S,
/** 忽略全局清除測試數(shù)據(jù)方法 */
GLOBAL_C,
/** 忽略局部準備測試環(huán)境方法 */
LOCAL_S,
/** 忽略局部清除測試數(shù)據(jù)方法 */
LOCAL_C,
/** 忽略全部準備測試環(huán)境方法 */
BOTH_SETUP,
/** 忽略全部清楚測試數(shù)據(jù)方法 */
BOTH_CLEAN,
/** 忽略全局準備測試環(huán)境和局部清除測試數(shù)據(jù)方法 */
GLOBAL_S_LOCAL_C,
/** 忽略全局清除測試數(shù)據(jù)和局部準備測試環(huán)境方法 */
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");
}
/**
* 測試插入一條新數(shù)據(jù).
*/
public void testInsert() {
execute(new TestExecutor() {
@Override
public void execute() {
bizUrlDAO.insert(generateDO());
assertNotNull(bizUrlDAO.getByUrl("www.easyjf.com"));
}
});
}
/**
* 測試重復插入數(shù)據(jù)的情況.
*/
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);
}
}
});
}
/**
* 測試刪除一條已經(jīng)存在的數(shù)據(jù).
*/
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()方法,我們傳入了兩個參數(shù),第一個參數(shù)IgnoralType.GLOBAL_C 代表忽略哪個方法,有12種類型可以設置。GLOBAL_C代表忽略全局的清除測試數(shù)據(jù)方法,其他見代碼注釋。