随着年龄越来越大,感觉自己的记忆力也越来越差了,老了。为了对自己现在学习到的知识能够记忆得更持久些,掌握得更深入些,我打算通过一系列的文章来总结自己学到的东西。这样做一方面可以锻炼自己的文笔,一方面可增强对所学知识的记忆力,最重要的是,通过自己写文字来进行归纳总结,可以迫使自己更加深入和全面的去掌握所学知识,迫使自己开动脑筋不断地思考,从而超越那种对知识肤浅的理解和应用层面上,将别人的知识真正变成自身的技能。
本文简要介绍EasyMock开源组件及其在单元测试中的实际应用经验。
众所周知,在软件开发过程中,单元测试是一个十分重要的活动。编写单元测试的时候我们需要关心目标程序内部的逻辑结构,以及目标代码与其它代码的相互依赖关系。通常情况下,目标代码不是孤立的,它会同其它模块协同工作,共同完成特定功能。我们可以把与目标代码协同工作的模块称之为目标代码的依赖项。
我们在编写单元测试用例的时候经常会遇到这样的问题:目标代码的依赖项很难构造。考虑以下几种情形:
对于上述情景,可以通过模拟的方式来尝试解决问题。如我们可以尝试构造目标代码的依赖项,使其按照我们的预期行为返回相应结果。但是,构造依赖项需要我们非常熟悉它的实现方式,如果这些依赖项是我们自己开发的,则构造它问题可能还不大。如果这些要构造的项是他人开发的或根本就是由第三方提供的,我们手动构造可能会非常之麻烦,甚至于都不大可能。如Servlet容器相关的Request/Response等对象、JDBC相关的ResultSet等对象,这些对象或者需要依赖特殊的上下文环境而存在,或者实现方式相当复杂,我们很难简单的通过手工方式模拟构造出来。
而且手动模拟对象必然会带来额外的编码量,可能需要我们花费大量的精力来构造一些原本与我们的核心业务无关的对象,而在这个过程中可能又引入了未知的错误。因此多数情况下,手工模拟并不是好的解决此类问题的方式。
既然手工模拟构造复杂对象的方式不可取,那么是否有办法自动构造对象呢?让我们即能获得需要的对象实例,又能避免其繁杂的构造过程。EasyMock组件就是致力于解决这个问题的一个非常优秀的开源框架。
EasyMock是一个用于为给定的接口自动生成实现类实例的类库,它基于JDK1.3后提供的动态代理支持,对指定的接口自动生成实现类。如果我们要模拟一个接口的行为,那么我们可以用EasyMock生成该接口的Mock对象,然后通过调用这个对象上的行为来模拟对这个接口的调用过程。
EasyMock模拟接口实现,通过录制、回放、检查三个步骤,来验证接口的行为是否与预期相符。它可以模拟接口方法的调用种类(以特定的参数调用指定的方法)、调用次数以及调用顺序,还能设置方法调用的返回值或指定其抛出特定异常。我们完全可以用EasyMock来模拟某些难以构造的依赖项的行为,从而可以获得孤立的测试环境,使得单元测试顺利进行。
EasyMock自动为接口生成Mock对象,其生成的对象上的实现方法必然不可能包含任何业务逻辑的,我们只是通过对Mock对象方法的调用次数、调用顺序、返回值等来实现对实际对象行为的模拟调用。
另外,EasyMock现在也支持为具体类生成Mock对象,这需要下载EasyMock Class Extension 2.2.2,实际上这是通过CGLib动态字节码生成技术来实现的,因此EasyMock的扩展需要CGLib支持。
使用EasyMock,可以参考如下步骤:
在单元测试中我们首先要准备好依赖项,面向接口编程是我们做Web开发的一个基本准则,我们代码的依赖项一般声明为接口,通常在系统运行时通过IoC的方式动态注入。我们通过EasyMock来为接口生成一个Mock对象。
EasyMock的createMock静态方法可以生成指定接口的Mock对象,该方法接收一个Class对象作为参数,如
HttpServletRequest request = EasyMock.createMock(HttpServletRequest.class);
EasyMock通过CreateMock方法创建了一个HttpServletRequest接口的模拟对象。
EasyMock.createMock方法用于生成一个Mock对象,在复杂一些的场景中,我们通常需要多个Mock对象来协同工作,除了通过createMock一个一个地来生成相应的Mock对象外,EasyMock还提供了一种更简便的创建和管理Mock对象的方法。EasyMock.createControl方法可以生成一个IMocksControl实例,通过IMocksControl就可以创建多个Mock对象并且统一管理这些对象。
IMocksControl control = EasyMock.createControl(); HttpServletRequest request = control.createMock(HttpServletRequest.class); HttpServletResponse response = control.createMock(HttpServletResponse.class); HttpSession session = control.createMock(HttpSession.class);
上述代码通过control创建了三个Mock对象,并且由Control来统一管理这三个对象。如可以通过control来统一设置这三个Mock对象的状态、统一完成对象检查等操作。
创建Mock对象后,我们就可以录制对象的行为了,录制过程可以通过如下步骤来完成:
如我们的代码会调用HttpServletRequest接口的Mock对象上的getParameter方法,那么可以通过如下代码来录制调用过程:
request.getParameter("name"); EasyMock.expectLastCall().andReturn("neil").times(1);
录制Mock对象行为的第一步,就是用特定的参数调用Mock对象上的指定方法。需要特别注意参数的问题,我们在代码中对Mock对象进行实际调用时所传递的参数,与此处录制时所使用的参数,是通过参数对象本身的equals方法来判定一致性的。如果不一致,则EasyMock认为对Mock对象的实际调用与录制的调用方式不符,就不能通过验证。因此如果以字符串作为参数,如上述的"name",就要注意字符大小写的问题,如果录制的参数与实际调用的参数大小写不一致,则EasyMock会报错。EasyMock提供了多种设置参数的方法来简化我们录制Mock对象行为的复杂度,具体内容可查阅JavaDoc。
expectLastCall()方法返回对Mock对象的调用结果设置器IExpectationSetters实例,然后调用IExpectationSetters实例上的andReturn方法设置对Mock对象行为调用的返回值,andReturn方法依然返回IExpectationSetters实例,因此可以继续调用它的times方法来设置对Mock对象行为的调用次数。
IExpectationSetters接口提供了很多方法来设置对Mock对象特定行为的调用的返回值,如andStubReturn可以设置一个固定的返回值,andThrow可以设置调用抛出的异常,而andAnswer与andDelegateTo提供了更为灵活的设置调用返回值的方式。
IExpectationSetters接口还提供了一些方法来设置对Mock对象特定行为的调用次数,如anyTimes()说明该行为可以被调用任意次数,atLeastOnce()说明该行为至少要被调用一次,once()说明该行为能而且只能被调用一次,times(int num)指定具体的调用次数,times(int min, int max)指定一个次数值的区间。
如果Mock对象上的方法是void的,即没有返回值,则无须设置对其调用的返回值,仅设置调用次数即可。
关于IExpectationSetters接口更详细的信息请查阅EasyMock的JavaDoc。
创建Mock对象后,设置Mock对象的预期行为和输出,此时Mock对象还处于录制状态,在对其进行实际的调用前,我们需要将其切换到Replay状态。录制状态记录了我们对Mock对象行为的期望,而切换到Replay状态后,我们在代码中实际调用Mock对象,这个实际的调用过程会被Mock对象记录下来,以便与录制的预期值进行对比,从而才能判断我们的代码对Mock对象的调用是否与我们的期望相符。
将Mock切换到Replay状态的方法,因Mock对象创建方式的不同而不同。
如果mock对象是通过EasyMock.createMock方法创建的,则我们以Mock对象作为参数直接调用EasyMock.replay(mockObject)方法即可。如:
//创建Mock对象 HttpServletRequest request = EasyMock.createMock(HttpServletRequest.class); //录制request的行为 ...... //将request切换到Replay状态 EasyMock.replay(request);
如果Mock对象是通过IMocksControl实例创建的,则我们调用IMocksControl实例的replay()方法,可以统一将其创建的多个Mock对象全部切换到Replay状态。
//创建Mock控制器实例 IMocksControl control = EasyMock.createControl(); //通过control创建多个Mock对象,由control统一对这些对象进行管理 HttpServletRequest request = control.createMock(HttpServletRequest.class); HttpServletResponse response = control.createMock(HttpServletResponse.class); HttpSession session = control.createMock(HttpSession.class); //录制这些Mock对象的行为 ...... //将这些Mock对象切换到Replay状态 control.replay();
将Mock对象切换到Replay状态后,我们就可以在单元测试中实际调用它了。实际上就是我们的目标代码对其依赖项的调用,而这些依赖项已经通过Mock对象准备好了。我们通过Mock对象隔离了目标代码与实际的依赖项之间的联系,使我们的代码不再依赖于外部环境,获得了孤立的测试环境。
我们在单元测试中完成对目标代码的调用后,如何验证目标代码对Mock对象的调用是正确无误的呢?我们前面已经录制了Mock对象的预期行为和输出结果,对Mock对象完成实际调用后,EasyMock提供了相应方法来检查实际调用结果。与将Mock对象切换到Replay状态的方法类似,EasyMock的检查方法也因Mock对象创建方式的不同而不同。如果Mock对象是通过EasyMock.createMock创建的,那么我们以Mock对象作为参数调用EasyMock.verify()方法即可完成检查;如果Mock对象是由IMocksControl.createMock方法创建的,则还是在IMocksControl实例上调用verify()方法就可以了。
下面简单介绍一下我在一个实际的单元测试用例中使用EasyMock的过程。
先简单介绍一下我这个用例的业务场景。我要做一个从数据库中查询监控数据的Service,查询条件是从Web界面上收集的,包括clien_ip、namespace、key三个指标,namespace要与用户一致,即当前登录用户只能查询自己namespace下的监控数据。为了节省时间,直接将我的目标代码及测试代码贴上,等有空再画下该功能的活动图或序列图。
public class MonitorManagerImpl implements MonitorManager { private MonitorDataDao monitorDataDao; private HeartBeatDao heartBeatDao; private NamespaceDao namespaceDao; public void setMonitorDataDao(MonitorDataDao monitorDataDao) { this.monitorDataDao = monitorDataDao; } public void setNamespaceDao(NamespaceDao namespaceDao) { this.namespaceDao = namespaceDao; } public void setHeartBeatDao(HeartBeatDao heartBeatDao) { this.heartBeatDao = heartBeatDao; } public List<MonitorDataForm> queryMonitorDatas(MonitorDataQuerier querier) { List<MonitorDataForm> ret = new ArrayList<MonitorDataForm>(); CirceUser user = querier.user; String ip = querier.ip; String namespace = querier.namespace; String key = querier.key; if (user == null) return ret; if (user.isAdmin()) { // 管理员则查询所有记录 List<MonitorData> list = monitorDataDao.queryMonitorDatas(ip, namespace, key); handleQueryResult(ret, list); } else { // 普通用户只能查询自己应用下的记录 List<NamespaceDO> namespaces = namespaceDao .listNamespacesByApplicant(user.getUserName()); if (!namespaces.isEmpty()) { if (namespace != null) { // 查询条件中包含namespace if (contains(namespaces, namespace)) { List<MonitorData> list = monitorDataDao .queryMonitorDatas(ip, namespace, key); handleQueryResult(ret, list); } } else { // 查询条件中不包含namespace List<String> ns = new ArrayList<String>(); for (NamespaceDO n : namespaces) { ns.add(n.getName()); } List<MonitorData> list = monitorDataDao.queryMonitorDatas( ip, ns, key); handleQueryResult(ret, list); } } } return ret; } private void handleQueryResult(List<MonitorDataForm> ret, List<MonitorData> list) { handleQueryResult(ret, list, true); } private void handleQueryResult(List<MonitorDataForm> ret, List<MonitorData> list, boolean accessTime) { if (list != null && list.size() > 0) { for (MonitorData d : list) { MonitorDataForm f = MonitorDataFormUtil.convertDO2Form(d); if(accessTime){ Date date = heartBeatDao.getLastAlive(f.getClientIp()); if(date != null){ f.setAccessTimeStr(Constants.FORMATTER.format(date)); } } ret.add(f); } } } private boolean contains(List<NamespaceDO> namespaces, String namespace) { boolean flag = false; for (NamespaceDO n : namespaces) { if (namespace.equals(n.getName())) { flag = true; break; } } return flag; } }
其中,queryMonitorDatas(MonitorDataQueries queries)是实现查询功能的主体方法,其它几个私有方法是实现功能的辅助方法。
public class MonitorManagerImplTest { private static MonitorManagerImpl monitorManager; private static MonitorDataDao monitorDataDao; private static NamespaceDao namespaceDao; private static HeartBeatDao heartBeatDao; private static IMocksControl control; @BeforeClass public static void init() { monitorManager = new MonitorManagerImpl(); // 通过IMocksControl实例创建若干Mock对象 control = createControl(); monitorDataDao = control.createMock(MonitorDataDao.class); namespaceDao = control.createMock(NamespaceDao.class); heartBeatDao = control.createMock(HeartBeatDao.class); // 注册依赖关系 monitorManager.setMonitorDataDao(monitorDataDao); monitorManager.setNamespaceDao(namespaceDao); monitorManager.setHeartBeatDao(heartBeatDao); } @Before public void setUp() { // 每次运行用例前重置Mock对象为录制状态 control.reset(); } @Test public void testqueryMonitorDatas1() { // 用户没有登录 MonitorDataQuerier q = new MonitorDataQuerier(); List<MonitorDataForm> ret = monitorManager.queryMonitorDatas(q); assertEquals(0, ret.size()); } @Test public void testqueryMonitorDatas2() { // admin用户登录,可以查询所有记录 MonitorDataQuerier q = new MonitorDataQuerier(); CirceUser user = new CirceUser(); user.setUserName("admin"); q.user = user; List<MonitorData> ret = new ArrayList<MonitorData>(); MonitorData f = new MonitorData(); f.setClientIp("10.16.44.35"); ret.add(f); f = new MonitorData(); f.setClientIp("10.16.44.36"); ret.add(f); Date d = new Date(); // 录制Mock对象行为,并设定输出结果 monitorDataDao.queryMonitorDatas(null, (String)null, null); expectLastCall().andReturn(ret); heartBeatDao.getLastAlive(EasyMock.matches("10.16.44.*")); expectLastCall().andReturn(d).times(2); // 切换到Replay状态 control.replay(); // 调用实际的功能代码 List<MonitorDataForm> list = monitorManager.queryMonitorDatas(q); assertEquals(2, list.size()); assertEquals(Constants.FORMATTER.format(d), list.get(0).getAccessTimeStr()); // 检查对Mock对象的实际调用过程是否与录制的预期一致 control.verify(); } @Test public void testqueryMonitorDatas3() { // 录制Mock对象行为,并设定输出结果 namespaceDao.listNamespacesByApplicant("user1"); List<NamespaceDO> ns = new ArrayList<NamespaceDO>(); NamespaceDO namespace = new NamespaceDO(); namespace.setName("n1"); ns.add(namespace); namespace = new NamespaceDO(); namespace.setName("n2"); ns.add(namespace); expectLastCall().andReturn(ns).times(3); monitorDataDao.queryMonitorDatas((String)EasyMock.eq(null), EasyMock.find("n"), (String)EasyMock.eq(null)); expectLastCall().andAnswer(new IAnswer<Object>() { public Object answer() throws Throwable { Object[] args = EasyMock.getCurrentArguments(); String namespace = (String)args[1]; List<MonitorData> ret = new ArrayList<MonitorData>(); if("n1".equals(namespace) || "n2".equals(namespace)){ MonitorData f = new MonitorData(); f.setClientIp("10.16.44.35"); f.setNamespace(namespace); ret.add(f); } return ret; } }).times(2); heartBeatDao.getLastAlive(EasyMock.matches("10.16.44.*")); Date d = new Date(); expectLastCall().andReturn(d).times(2); // 切换到Replay状态 control.replay(); // 普通用户登录,可以查询自己应用下的所有记录 MonitorDataQuerier q = new MonitorDataQuerier(); CirceUser user = new CirceUser(); user.setUserName("user1"); q.user = user; q.namespace = "n1"; // 以不同的查询条件多次调用实际的功能代码 List<MonitorDataForm> list = monitorManager.queryMonitorDatas(q); assertEquals(1, list.size()); assertEquals(Constants.FORMATTER.format(d), list.get(0).getAccessTimeStr()); q.namespace = "n2"; list = monitorManager.queryMonitorDatas(q); assertEquals(1, list.size()); assertEquals(Constants.FORMATTER.format(d), list.get(0).getAccessTimeStr()); q.namespace = "n3"; list = monitorManager.queryMonitorDatas(q); assertEquals(0, list.size()); // 检查对Mock对象的实际调用过程是否与录制的预期一致 control.verify(); } }
// 简单debug过,其核心类貌似不多,但是没有时间深入了,以后有空时可以详细研究下
EasyMock是对接口方法的简单模拟,使用EasyMock能够隔离协作模块获得孤立的测试环境,方便编写单元测试用例。
但是,必须清醒认识到,模拟绝不是实际的实现,它应该只是在万般无奈下的选择而已,只能要用实际的代码尽量就不要用Mock。因为很多时候我们不能保证我们的协作模块是正确无误的,必须对其进行实际的调用才可以确认;还有一种情况使得Mock难以发挥作用:我们调用协作模块并不是为了获得特定的输出,而是为了改变上下文的状态,对环境数据施加某些影响,这种上下文状态的变化很可能是非常难以通过代码直接侦测的,通常在开发框架的底层代码时这种情况尤为常见。
EasyMock是一种粗粒度的对象协作方法,在对业务功能代码的测试中用处比较明显,用来测试框架代码则有些力不从心了。
EasyMock 使用方法与原理剖析:http://www.ibm.com/developerworks/cn/opensource/os-cn-easymock/