【IT168 技术文章】最近,人们对极端编程(Extreme Programming,XP)的关注已经扩大到它的一个最具可移植性的应用上:单元测试和最初测试设计。因为软件工作室已经开始采用 XP 的开发方法,我们可以看到,因为有了一套全面的单元测试工具,很多开发者的开发质量和速度都得到了提高。但编写好的单元测试耗时费力。因为每个单元都与其它单元合作,所以编写单元测试可能需要大量的设置代码。这使得测试变得更加昂贵,而且在特定情况下(比如代码充当远程系统的客户机时),这样的测试可能几乎无法实现。
在 XP 中,单元测试弥补了集成测试和验收测试的不足。后两种测试类型可能由独立的小组进行,或者作为独立的活动进行。但是单元测试是与要测试的代码同时编写的。面对日益逼近的截止期限和令人头痛的单元测试带来的压力,我们很可能随便编写一个测试了事,或者完全放弃测试。因为 XP 依赖于积极的动机和自给自足的习惯,所以 XP 过程(和项目!)的非常好的利益就是使测试保持集中和易于编写。
模仿对象可以帮助您解决这种进退两难的局面。模仿对象测试用只用于测试的模仿实现来替代和域相关的东西。然而,这种策略的确在某些情况下带来了技术上的难题,比如远程系统上的单元测试。AspectJ 是 Java 语言的一种面向 aspect 的扩展,它允许我们在传统的面向对象方法失败的地方代之以 test-only 行为,从而用其它方法进行单元测试。
在本文中,我们将讨论一种编写单元测试既困难又合乎需要的常见情况。我们将从为一个基于 EJB 的应用程序的客户机组件运行单元测试开始。我们将使用这个示例作为出发点,来讨论在远程客户机对象上进行单元测试时可能出现的一些问题。为了解决这些问题,我们将开发两个新的依赖于 AspectJ 和模仿对象的测试配置。看到文章末尾时,您就应该对常见的单元测试问题和它们的解决方案有所了解,还应该初步了解 AspectJ 和模仿对象测试提供的一些有趣的可能性。
单元测试示例
示例由 EJB 客户机的一个测试组成。本案例研究中提出的很多问题都适用于调用 Web 服务的代码、调用 JDBC 的代码、甚至本通过虚包调用的本地应用程序“远程”部分的代码。
服务器端的 CustomerManager EJB 执行两种功能:它查找客户名并向远程系统注册新客户名。清单 1 展示了 CustomerManager 公开给客户机的接口:
清单 1. CustomerManager 的远程接口
2 /**
3 * Returns a String[] representing the names of customers in the system
4 * over a certain age.
5 */
6 public String[] getCustomersOver(int ageInYears) throws RemoteException;
7 /**
8 * Registers a new customer with the system. If the customer already
9 * exists within the system, this method throws a NameExistsException.
10 */
11 public void register(String name)
12 throws RemoteException, NameExistsException;
13 }
14
客户机代码名为 ClientBean ,它本质上将公开相同的方法,将实现这些方法的任务交给 CustomerManager ,如清单 2 所示。
清单 2. EJB 客户机代码
2 /**
3 * Returns a String[] representing the names of customers in the system
4 * over a certain age.
5 */
6 public String[] getCustomersOver(int ageInYears) throws RemoteException;
7 /**
8 * Registers a new customer with the system. If the customer already
9 * exists within the system, this method throws a NameExistsException.
10 */
11 public void register(String name)
12 throws RemoteException, NameExistsException;
13 }
14
我有意将这个单元写得简单一点,这样我们就可以将精力集中在测试上。 ClientBean 的接口与 CustomerManager 的接口只有一点点不同。与 ClientManager 不同, ClientBean 的 register() 方法将返回一个布尔值,而且在客户已经存在的时侯不会抛出异常。这些就是好的单元测试应该验证的功能。
清单 3 所示的代码将实现 ClientBean 的 JUnit 测试。其中有三个测试方法,一个是 getCustomers() 的,另外两个是 register() 的(其中一个是成功的,另一个是失败的)。测试假定 getCustomers() 将返回一个有 55 个条目的列表, register() 将为 EXISTING_CUSTOMER 返回 false ,为 NEW _CUSTOMER 返回 true 。
清单 3. ClientBean 的单元测试
2 /**
3 * Returns a String[] representing the names of customers in the system
4 * over a certain age.
5 */
6 public String[] getCustomersOver(int ageInYears) throws RemoteException;
7 /**
8 * Registers a new customer with the system. If the customer already
9 * exists within the system, this method throws a NameExistsException.
10 */
11 public void register(String name)
12 throws RemoteException, NameExistsException;
13 }
14
如果客户机返回了预期的结果,那么测试就将通过。虽然这个测试非常简单,您还是可以轻易地想象同样的过程会如何应用到更复杂的客户机上,比如根据对 EJB 组件的调用生成输出的 servlet。
如果您已经安装了样本应用程序,那么请试着用示例目录中的命令 ant basic 运行这个测试若干次。
依赖数据的测试的问题
在运行了几次上述测试后,您就会注意到结果是不一致的:有时候测试会通过,有时候不会通过。这种不一致性归咎于 EJB 组件的实现 ― 而不是客户机的实现。示例中的 EJB 组件模拟了一个不确定的系统状态。测试数据中的不一致性显示出了在实现简单的、以数据为中心的测试时将出现的实际问题。另一个比较大的问题就是容易重复测试工作。我们将着手解决这里的两个问题。
数据管理
克服数据中不确定性简单的方法就是管理数据的状态。如果我们能够设法在运行单元测试之前保证系统中有 55 条客户记录,那么我们就可以确信 getCustomers() 测试中的任何失败情况都可以表明代码中有缺陷,而不是数据问题。但是管理数据状态也会带来它自己的一些问题。您必须在运行每个测试之前确保系统对于特定测试处于正确的状态。如果您缺乏警惕,那么其中一个测试的结果就可能以某种方式改变系统的状态,而这种方式将使下一个测试失败。
为了应付这种负担,您可以使用共享设置类或批输入进程。但这两种方法都意味着要对基础结构作出很多投入。如果应用程序在某种类型的存储设备上持久化它的状态,您可能还会碰到更多问题。向存储系统添加数据可能很复杂,而且频繁的插入和删除可能使测试的执行非常缓慢。
有时候情况比碰到状态管理的问题还要糟糕,那就是完全无法实现这种管理。当您为第三方服务测试客户机代码时,您就可能发现自己处于这种情况下。只读类型的服务可能不会将改变系统状态的能力公开,或者您可能因为商业原因失去了插入测试数据的信心。举例来说,向活动的处理队列发送测试命令就很可能是个糟糕的想法。
重复的工作
即便您可以完全控制系统状态,基于状态的测试还是可以产生不需要的重复测试工作 ― 而且您不希望第二次编写相同的测试。
让我们将测试应用程序作为示例。如果我控制 CustomerManager EJB 组件,那么我就已经拥有了一个可以验证组件行为正确性的测试。我的客户机代码实际上并不执行任何与向系统添加新的客户相关的逻辑;它只是将操作交给 CustomerManager 。那么,我为什么要在这里重新测试 CustomerManager 呢?
如果某个人改变了 CustomerManager 的实现以使其对相同数据作出不同响应,我就必须修改两个测试,从而跟踪改变。这有一点过耦合测试的味道。幸运的是,这样的重复是不必要的。如果我可以验证 ClientBean 与 CustomerManager 正确通信的话,我就有足够证据证明 ClientBean 是按其工作方式工作的。模仿对象测试恰恰允许您执行这种验证。
模仿对象测试
模仿对象使单元测试不会测试太多内容。模仿对象测试用模仿实现来代替真正的合作者。而且模仿实现允许被测试的类和合作者正确交互的简单验证。我将用一个简单的示例来演示这是如何实现的。
我们测试的代码将从客户机-服务器数据管理系统删除一个对象列表。清单 4 展示了我们要测试的方法:
清单 4. 一个测试方法
2 void delete();
3 }
4 public class Deleter {
5 public static void delete(Collection deletables){
6 for(Iterator it = deletables.iterator(); it.hasNext();){
7 ((Deletable)it.next()).delete();
8 }
9 }
10 }
11
简单的单元测试就可能创建一个真正的 Deletable ,然后验证它在调用 Deleter.delete() 后将消失。然而,为了使用模仿对象测试 Deleter 类,我们编写了一个实现 Deletable 的模仿对象,如清单 5 所示:
清单 5. 一个模仿对象测试
2 private boolean deleteCalled;
3 public void delete(){
4 deleteCalled = true;
5 }
6 public void verify(){
7 if(!deleteCalled){
8 throw new Error("Delete was not called.");
9 }
10 }
11 }
12
下面,我们将在 Deleter 的单元测试中使用模仿对象,如清单 6 所示:
清单 6. 一个使用模仿对象的测试方法
2 MockDeletable mock1 = new MockDeletable();
3 MockDeletable mock2 = new MockDeletable();
4 ArrayList mocks = new ArrayList();
5 mocks.add(mock1);
6 mocks.add(mock2);
7 Deleter.delete(mocks);
8 mock1.verify();
9 mock2.verify();
10 }
11
在执行时,该测试将验证 Deleter 成功地调用集合中每个对象上的 delete() 。模仿对象测试按这种方式精确地控制被测试类的环境,并验证单元与它们正确地交互。
模仿对象的局限性
面向对象的编程限制了模仿对象测试对被测试类的执行的影响。举例来说,如果我们在测试一个稍微不同的 delete() 方法 ― 也许是在删除一些可删除对象之前查找这些对象的列表的方法 ― 测试就不会这么容易地提供模仿对象了。下面的方法使用模仿对象可能很难测试:
清单 7. 一个很难模仿的方法
2 Collection deletables = fetchThemFromSomewhere(criteria);
3 for(Iterator it = deletables.iterator(); it.hasNext();){
4 ((Deletable)it.next()).delete();
5 }
6 }
7
模仿对象测试方法的支持者声称,像上面这样的方法应该被重构,以使其更加“易于模仿”。这种重构往往会产生更简洁、更灵活的设计。在一个设计良好的系统中,每个单元都通过定义良好的、支持各种实现(包括模仿实现)的接口与其上下文进行交互。
但即便在设计良好的系统中,也有测试无法轻易地影响上下文的情况出现。每当代码调用可全局访问的资源时,就会出现这种情况。举例来说,对静态方法的调用很难验证或替换,就像使用 new 操作符进行对象实例化的情况一样。
模仿对象对全局资源不起作用,因为模仿对象测试依赖于用共享通用接口的测试类手工替换域类。因为静态方法调用(和其它类型的全局资源访问)不能被覆盖,所以不能用处理实例方法的方式来“重定向”对它们的调用。
您可以向清单 4 中的方法传送 任何 Deletable ;然而,因为无法在真正类的地方装入不同的类,所以您不能使用 Java 语言的模仿方法调用替换静态方法调用。
一个重构示例
有些重构常常能够使应用程序代码向良好的解决方案发展,这种解决方案也可以容易地测试 ― 但事情并不总是这样。如果得出的代码更难维护或理解,为了能够测试而进行重构并没有意义。
EJB 代码可能更加难于重构为允许轻易地模仿测试的状态。举例来说,易于模仿的一种重构类型将改变下面这种代码:
2 public void doSomething(){
3 EJBNumber2 collaborator = lookupEJBNumber2();
4 //do something with collaborator
5 }
6
改为这种代码:
2 //do something with collaborator
3 }
4
在标准的面向对象系统中,这个重构示例允许调用者向给定单元提供合作者,从而增加了灵活性。但这种重构在基于 EJB 的系统中可能是不需要的。由于性能原因,远程 EJB 客户机需要尽可能多地避免远程方法调用。第二种方法需要客户机首先查找,然后创建 EJBNumber2 (一个与若干远程操作有关的进程)的实例。
另外,设计良好的 EJB 系统倾向于使用“分层”的方法,这时客户机层不需要了解实现细节(比如 EJBNumber2 的存在等)。获取 EJB 实例的首选方法是从 JNDI 上下文查找工厂( Home 接口),然后调用工厂上的创建方法。这种策略给了 EJB 应用程序很多重构代码样本需要的灵活性。因为应用程序部署者可以在部署时在完全不同的 EJBNumber2 实现中交换,所以系统的行为可以轻易地进行调整。然而,JNDI 绑定不能轻易地在运行时改变。因此,模仿对象测试者面临两种选择,一是为了在 EJBNumber2 的模仿中交换而重新部署,二是放弃整个测试模型。
幸运的是,AspectJ 提供了一个折衷方法。