如何编写干净的单元测试用例
——Callback & Template Pattern在单元测试中的应用
关键词:Callback Function 回调模式 Template Method 模板方法 单元测试
目标读者:开发工程师
级别:初、中级
篇首语
本文假设读者已经熟悉单元测试及JUnit工具的使用,如果对单元测试及JUnit尚不了解请先学习单元测试及JUnit工具的相关知识。读者最好对Spring框架及Spring框架提供的单元测试支持有所了解,因为本文案例基于Spring技术编写。但对Spring不了解并不影响本文所讲述的单元测试用例编写及回调模式、模板方法的应用。
单元测试是编写高质量代码的前提,通过编写有效的单元测试即可以保证代码的质量又可以提高开发速度,因为大多数问题都可以通过单元测试发现并解决而不需要部署到应用服务器。纵览网上流行的优秀开源框架,无一不提供完整的单元测试用例。Spring框架便是其中的代表和佼佼者,因为Spring所遵循的控制反转(IoC)和依赖注入(DI)原则使编写有效、干净的单元测试用例变得更加方便、快捷。
编写单元测试用例
本文所采用的案例非常简单,就是对数据库表的增、删、改、查操作进行测试。假设我们有这样一个表url(MySql数据库):
字段 |
类型 |
描述 |
id |
int |
主键,自增类型 |
url |
varchar |
网站地址,唯一不能重复 |
|
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("[email protected]"); 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("[email protected]"); return bizUrlDO; } public void setBizUrlDAO(BizUrlSynchronzieDAO bizUrlDAO) { this.bizUrlDAO = bizUrlDAO; } }
注意testDeleate()方法,我们传入了两个参数,第一个参数IgnoralType.GLOBAL_C 代表忽略哪个方法,有12种类型可以设置。GLOBAL_C代表忽略全局的清除测试数据方法,其他见代码注释。
结束语
本文以单元测试为环境,讲解了模板方法和回调函数模式的应用,但不局限于单元测试环境,读者可以在理解和掌握的基础上在任何程序中应用这些模式。由于笔者能力有限,文中难免存在错误之处,诚挚欢迎大家的批评和意见。