篇首語
本文假設(shè)讀者已經(jīng)熟悉單元測試及JUnit工具的使用,如果對(duì)單元測試及JUnit尚不了解請先學(xué)習(xí)單元測試及JUnit工具的相關(guān)知識(shí)。讀者最好對(duì)Spring框架及Spring框架提供的單元測試支持有所了解,因?yàn)楸疚陌咐?/span>Spring技術(shù)編寫。但對(duì)Spring不了解并不影響本文所講述的單元測試用例編寫及回調(diào)模式、模板方法的應(yīng)用。
單元測試是編寫高質(zhì)量代碼的前提,通過編寫有效的單元測試即可以保證代碼的質(zhì)量又可以提高開發(fā)速度,因?yàn)榇蠖鄶?shù)問題都可以通過單元測試發(fā)現(xiàn)并解決而不需要部署到應(yīng)用服務(wù)器。縱覽網(wǎng)上流行的優(yōu)秀開源框架,無一不提供完整的單元測試用例。Spring框架便是其中的代表和佼佼者,因?yàn)?/span>Spring所遵循的控制反轉(zhuǎn)(IoC)和依賴注入(DI)原則使編寫有效、干凈的單元測試用例變得更加方便、快捷。
編寫單元測試用例
本文所采用的案例非常簡單,就是對(duì)數(shù)據(jù)庫表的增、刪、改、查操作進(jìn)行測試。假設(shè)我們有這樣一個(gè)表url(MySql數(shù)據(jù)庫):
字段
|
類型
|
描述
|
id |
int |
主鍵,自增類型 |
url |
varchar |
網(wǎng)站地址,唯一不能重復(fù) |
|
varchar |
Email地址 |
name |
varchar |
名稱 |
正如你所見,該表只有幾個(gè)字段,但對(duì)于我們的案例來說完全夠用。
看到此處,你應(yīng)該清楚我們是要對(duì)數(shù)據(jù)庫操作進(jìn)行單元測試。如果你是一位經(jīng)驗(yàn)豐富的開發(fā)人員,此時(shí)已經(jīng)會(huì)有許多疑問,甚至已經(jīng)失去繼續(xù)閱讀本文的興趣:
² 單元測試不應(yīng)該直接操作數(shù)據(jù)庫?
² 對(duì)數(shù)據(jù)庫操作的單元測試可以采用DAO模式,Mock一個(gè)實(shí)現(xiàn)類?
² 使用內(nèi)存數(shù)據(jù)庫?
² 其他?
數(shù)據(jù)庫表有了,我們接下來編寫DAO及其實(shí)現(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實(shí)現(xiàn)類,該類繼承一個(gè)支持類,封裝了對(duì)數(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領(lǐng)域?qū)ο?/span>
/** * @author tao.youzt */ public class BizUrlDO { private int id; private String url; private String email; private String name; // getter and setter }
因?yàn)楸疚陌咐褂?/span>Spring作為底層框架,因此這里需要編寫Spring配置文件對(duì)DAO進(jìn)行組裝。
DAO及其配置文件都已經(jīng)準(zhǔn)備完畢,我們接下來編寫測試用例。Spring為單元測試提供了很多有用的支持類,我們在這里使用的是:
org.springframework.test.AbstractDependencyInjectionSpringContextTests
|
該類提供了POJO屬性自動(dòng)注入的能力,只要為為你的屬性字段提供一個(gè)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會(huì)根據(jù)該配置文件自動(dòng)注入bizUrlDAO屬性。testInsert()方法用于測試插入新數(shù)據(jù),注意這里有個(gè)問題,如果數(shù)據(jù)庫中已經(jīng)存在該URL的記錄,則應(yīng)用會(huì)報(bào)錯(cuò),所以這里還要進(jìn)行數(shù)據(jù)清除準(zhǔn)備處理,我們稱之為“測試環(huán)境準(zhǔn)備”,以后會(huì)用到該名詞;testDuplicateInsert()方法用于測試插入重復(fù)數(shù)據(jù)的情況,該方法同樣存在上面的問題;testDelete()方法用于測試刪除數(shù)據(jù)的情況,這里盡管準(zhǔn)備了數(shù)據(jù),但仍沒有考慮數(shù)據(jù)庫中已經(jīng)有記錄的情況。
綜上所述,盡管該測試類已經(jīng)比較清晰,但仍然存在許多不足之處。我們將在后面的章節(jié)進(jìn)行詳細(xì)分析,并給出解決方案。
Callback Function & Template Method Pattern
回調(diào)函數(shù)(Callback Function)和模板方法(Template Method)是軟件架構(gòu)設(shè)計(jì)中最常用的兩種設(shè)計(jì)模式,這兩種設(shè)計(jì)模式在Spring框架中隨處可見。
關(guān)于本節(jié)是否要詳細(xì)介紹回調(diào)函數(shù)(Callback Function)和模板方法(Template Method)模式的問題,筆者考慮了很長時(shí)間。因?yàn)榫W(wǎng)絡(luò)上對(duì)這兩種普遍使用的設(shè)計(jì)模式的定義層出不窮,各有各的道理,很難說誰是誰非。況且,針對(duì)不同的應(yīng)用場景,這兩種模式也有許多變體,或者聯(lián)合使用。
因此,筆者最終決定不在此處對(duì)這兩種模式做任何定義或引用,請讀者自行參閱相關(guān)文檔資料。
回調(diào)函數(shù)和模板方法模式在單元測試中的應(yīng)用
上一節(jié)我們簡單的回顧了回調(diào)函數(shù)和模板方法模式,Spring框架中大量采用了這兩種設(shè)計(jì)模式,有興趣的讀者可以閱讀Spring框架代碼進(jìn)一步鞏固對(duì)這兩種模式的理解和運(yùn)用。本節(jié)將結(jié)合回調(diào)函數(shù)模式和模板方法模式對(duì)前面的測試用例進(jìn)行重構(gòu),讀者可以在重構(gòu)過程中逐步了解這兩種設(shè)計(jì)模式的運(yùn)用。
首先,讓我們簡單總結(jié)一下前面測試用例的問題:
一、 抽象層次太低,不夠通用?
例如,對(duì)于getConfigLocations()方法,我們完全可以放到一個(gè)父類中實(shí)現(xiàn),因?yàn)閷?duì)于一個(gè)項(xiàng)目而言,其配置文件大多都是統(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" }; } } /** * 子類可以覆蓋該方法加載個(gè)性化配置. * * @return */ protected String[] getCustomConfigLocations() { return null; } }
如圖所示,我們提煉了一個(gè)抽象支持類,實(shí)現(xiàn)了getConfigLocations()方法,同時(shí)還提供了getCustomConfigLocations()方法供子類使用,子類可以通過重載該方法提供定制的配置。
有了該支持類,具體測試類只需要繼承該類并編寫測試邏輯即可。
二、 缺少準(zhǔn)備測試環(huán)境和清除測試數(shù)據(jù)的環(huán)節(jié)?
對(duì) 于大多數(shù)測試用例,可能都會(huì)涉及到初始化數(shù)據(jù)和清除測試數(shù)據(jù)的問題,最典型的就是數(shù)據(jù)庫操作,這也是本文采用數(shù)據(jù)庫操作作為案例的原因。那么如何實(shí)現(xiàn)呢? 很顯然在每個(gè)測試方法中都編寫準(zhǔn)備環(huán)境和清除測試數(shù)據(jù)的代碼是不合適的,因?yàn)榇蠖鄶?shù)時(shí)候?qū)τ谝粋€(gè)測試類而言,準(zhǔn)備環(huán)境和清除數(shù)據(jù)的邏輯都是一樣的。聰明的 你一定會(huì)想到定義兩個(gè)方法,一個(gè)初始化環(huán)境,一個(gè)清除測試數(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()兩個(gè)方法,分別用于初始化環(huán)境和清除測試數(shù)據(jù),然后在測試方法開始和結(jié)束時(shí)分別調(diào)用這兩個(gè)方法。這的確達(dá)到了我們的目的,不用在每個(gè)測試方法中都編寫初始化和清除邏輯!但此時(shí)你一定發(fā)現(xiàn)在每個(gè)測試方法前后都調(diào)用setupEnv()和cleanEnv()也很不爽,那說明我們的抽象程度還不夠!那么該如何做的更好呢?
這里該到模板方法(Template Method)模式發(fā)揮威力的時(shí)候了。我們將使用模板方法來繼續(xù)重構(gòu)前面的案例。讓我們先來定義一個(gè)方法:
/** * @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)有了一些進(jìn)步,定義了一個(gè)execute方法,在該方法開始和結(jié)束分別執(zhí)行初始化和清除邏輯,然后由doTestLogic()方法實(shí)現(xiàn)測試邏輯。實(shí)際測試方法中只要執(zhí)行execute方法,并傳入測試邏輯就可以了。瞧,不經(jīng)意間我們已經(jīng)實(shí)現(xiàn)了模板方法模式——把通用的邏輯封轉(zhuǎn)起來,變化的部分由具體方法提供。怎么,不相信么?呵呵,設(shè)計(jì)模式其實(shí)并不復(fù)雜,就是前人解決通用問題的一些最佳實(shí)踐總結(jié)而已。
此時(shí)你可能會(huì)說,TeseCase類已經(jīng)提供了setUp()和tearDown()方法來做這件事情,我也想到了,哈哈!但這并不和本文產(chǎn)生沖突!
可能此時(shí)此刻你已經(jīng)想到,本文另一個(gè)重要概念——回調(diào)方法模式還沒有用到,是不是該使用該模式了?沒錯(cuò),就是它了!我先把代碼給出,然后再詳細(xì)解釋。
我們提供了一個(gè)抽象類TestExecutor,并定義一個(gè)抽象的execute方法,然后為測試類的execute方法傳入一個(gè)TestExecutor的實(shí)例,并調(diào)用該實(shí)例的execute方法。最后,我們的測試方法中只需要new一個(gè)TestExecutor,并在execute方法中實(shí)現(xiàn)測試邏輯,便可以按照預(yù)期的方式執(zhí)行:準(zhǔn)備測試環(huán)境-執(zhí)行測試邏輯-清除測試數(shù)據(jù)。這便是一個(gè)典型的回調(diào)方法模式的應(yīng)用!
模板方法和回調(diào)函數(shù)模式說起來挺懸,其實(shí)也就這么簡單,明白了吧:)
三、 如何為每個(gè)測試方法單獨(dú)提供環(huán)境方法呢?
通過前面的講解,相信大家對(duì)模板方法和回調(diào)函數(shù)模式都已經(jīng)掌握了,這里直接給出相關(guān)代碼:
/** * 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" }; } } /** * 子類可以覆蓋該方法加載個(gè)性化配置. * * @return */ protected String[] getCustomConfigLocations() { return null; } /** * 準(zhǔn)備測試環(huán)境. */ protected void setupEnv() { } /** * 清除測試數(shù)據(jù). */ protected void cleanEvn() { } /** * 測試用例執(zhí)行器. */ protected abstract class TestExecutor { /** * 準(zhǔn)備測試環(huán)境 */ public void setupEnv() { } /** * 執(zhí)行測試用例. */ public abstract void execute(); /** * 清除測試數(shù)據(jù). */ public void cleanEnv() { } } /** * 執(zhí)行一個(gè)測試用例. * * @param executor */ protected final void execute(final TestExecutor executor) { execute(IgnoralType.NONE, executor); } /** * 執(zhí)行一個(gè)測試用例. * * @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)境相關(guān)方法 */ NONE, /** 忽略全局環(huán)境相關(guān)方法 */ GLOBAL, /** 忽略局部環(huán)境相關(guān)方法 */ LOCAL, /** 忽略所有環(huán)境相關(guān)方法 */ BOTH, /** 忽略全局準(zhǔn)備測試環(huán)境方法 */ GLOBAL_S, /** 忽略全局清除測試數(shù)據(jù)方法 */ GLOBAL_C, /** 忽略局部準(zhǔn)備測試環(huán)境方法 */ LOCAL_S, /** 忽略局部清除測試數(shù)據(jù)方法 */ LOCAL_C, /** 忽略全部準(zhǔn)備測試環(huán)境方法 */ BOTH_SETUP, /** 忽略全部清楚測試數(shù)據(jù)方法 */ BOTH_CLEAN, /** 忽略全局準(zhǔn)備測試環(huán)境和局部清除測試數(shù)據(jù)方法 */ GLOBAL_S_LOCAL_C, /** 忽略全局清除測試數(shù)據(jù)和局部準(zhǔn)備測試環(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")); } }); } /** * 測試重復(fù)插入數(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()); } }); } /** * 生成一個(gè)用于測試的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()方法,我們傳入了兩個(gè)參數(shù),第一個(gè)參數(shù)IgnoralType.GLOBAL_C 代表忽略哪個(gè)方法,有12種類型可以設(shè)置。GLOBAL_C代表忽略全局的清除測試數(shù)據(jù)方法,其他見代碼注釋。