Rational Edge: 对复杂的单元测试使用模拟对象

本文来自于 Rational Edge:为创建使用模拟对象的单元测试,使用相对简单的技术产生更多的无缺陷代码。

Gary Pollice, Professor of Practice, Worcester Polytechnic Institute

Author photoGary Pollice 是伍斯特市的伍斯特工学院的一名实践学教授。他教授软件工程、设计、测试以及其他计算机科学方面的课程,他还指导学生的设计。在加入学术界之前,他用了 35 年多的时间来开发不同种类的软件,从商业开发软件到编译器及工具软件。他最后一份产业界的工作是 IBM Rational 软件,在那里他被认为是“RUP的怪老头”,他还是最早的 Rational Suite 团队成员之一。他是 Software Development for Small Teams: A RUP-Centric Approach 一书的主要作者,该书于 2004 年由 Addison-Wesley 出版。Gary Pollice 拥有数学学士及计算机科学硕士学位。



2006 年 12 月 14 日

illustration如今,程序员比以往更多地认识到他们有责任创建编写较好的单元测试。无论一个开发人员是在进行测试驱动的开发(TDD)还是在编写代码后创造单元测试,有一点是十分明显的,那就是单元测试有助于产出高质量、无缺陷的代码。

即便开发人员知道测试的益处,我们也会发现程序员们不愿意测试他们的代码。他们会列出各种理由,如时间不够、没有有效的工具以及在编写带有许多有依赖关系的对象的测试方面有问题。

本期的文章中,我想将重点放在单元测试上,并看看如何解决这些问题。特别地,我想提供一些技巧来说明编写带有模拟对象的单元测试是多么容易。

对于单元测试的常见异议

在深入探讨模拟对象之前,让我们先来看一下以下两点异议。

花费时间太长

我们很早就认识到这样一个原则“做事情需要花费时间”,特别当这些事情值得去做。很少的开发人员会怀疑整体测试的价值,因此我们需要考虑如何定义“太长”这个词的含义。

开发人员们缺乏耐心,他们想要的是结果。他们喜欢写代码、运行代码然后看结果。从这一点来说,单元测试对他们有帮助。单元测试满足开发人员们的及时需求,但是许多程序开发人员认为编写测试占据了他们编写应用程序代码的时间,而他们的工作是按照后者计算报酬的。当然如果您仅仅按照程序开发人员在一个特定时间创建的应用程序代码的行数(或者一些其他方法)来计算的话,这一点是正确的。但是我们必须考虑每行代码所承载的全部时间。如果每当代码编译完成并运行通过我们就停止计算的话,我们可能会忽略掉创建软件最重要的部分—消除缺陷。在软件开发周期中越晚发现缺陷,修复缺陷所花费的代价会随之成倍增长。在开发过程中许多预先的质量检验会多占用一点点时间,但是会在以后节约大量时间。这一点已经被许多研究所证实。

仍有许多程序设计人员认为找出他们代码中的错误是其他一些人的工作。我发现近十年来这种情况已经有了显著的改进,但是仍有大量的程序人员并没有为他们的工作负全责,他们也不使用有助于改进他们代码的工具和技术。在早期的软件工程课程中我向我的学生们介绍过单元测试。我告诉他们如何使用现代工具编写测试。我布置了关于编写单元测试的作业。然而,当给他们机会在工作中采用有效的单元测试时,只有25%的学生这样做。原因是什么呢?因为他们还没有意识到测试的重要性。他们的直觉战胜了理性。他们知道单元测试的价值,但他们选择不予理会。

单元测试并不需要花费很长时间,但许多程序设计员认为它需要。作为一名教育工作者,我需要努力地在学生们职业生涯的早期就改变这一认识,并在他们整个学习过程中不断的加以强化。商业组织必须跟上步伐,在他们雇用毕业生时使得单元测试成为一份宝贵的实践。

低效的工具

这充其量是一个乏味的借口。在今天有很多有效的单元测试工具可供开发人员们使用。不管您使用的是什么程序语言或者其他的开发工具,单元测试工具都可以供您使用。许多工具都是开源或者免费的。

我选择 Eclipse 作为我的主要开发环境。在我现有的 Eclipse 配置里可以得到的所有单元测试工具中,我主要使用的是 Junit 测试框架。大多数 Java 设计人员都知道 Junit 并且大概至少使用过一次。JUnit 是 Eclipse 的 Java 开发工具中的一个完整部分。这个平台使得创建 Junit 测试变得简单。我只需在浏览器包里选择一个 Java 源文件,并在右击已选文件时从关系菜单里选择 New>JUnit Test Case(见图1)即可。提供的支持包括在测试中为类自动创建测试方式以及更多的东西。运行测试和创建一样简单。Eclipse 带有一个独立的视图可以观察 Junit 测试的结果。

Figure 1

图1:在 Eclipse 中创建一个 Junit 测试

Eclipse 中集成了大量的单元测试工具。其中许多是基于 Junit 并在性能上有所扩充。我让我的学生们在面向对象的设计类中使用 Coverlipse 插件程序来检测他们的测试的代码覆盖率。我希望在他们所有的应用程序代码中有100%的覆盖率。一开始他们不喜欢这样,但是到了期中,他们的测试的覆盖率通常都达到100%。

我已经为 TestNG,、djUnit、 Eclipse Test 和 Performance Tools Platform (TPTP) 安装了插件程序。其中每一个都有一组特性支持有效的单元测试。关键是有大量合适的单元测试工具提供给每一个开发人员,因此缺少工具不再被认为是没有创建单元测试的理由。

测试带有复杂的依赖性?使用模拟对象

一个好的单元测试检测一个独立的方法。在一个设计良好的系统里,对象们协同工作共同完成一项任务;因此,为了检验一种方法,通常我们需要提供使得这种方法完成其任务的其他对象。企业应用程序里的对象相当复杂,很难创建,并且他们的状态依赖外部的对象。一个数据库相关的应用程序有许多这样的对象,如连接、语句、结果集等等。我们想要单元测试简单快速的执行。如果我们需要在每次单元测试前将数据库重新设置到一个已知状态,那么测试会相当复杂并且运行速度自然会比我们期望得要慢。

简化单元测试的一个流行技巧就是创建仅用于测试中的模拟对象。为了达到快速测试的目的,我们创建模拟对象来代替真实的对象。模拟对象被 Tim Mackinnon、 Steve Freeman 和 Philip Craig1 所支持并成为单元测试工具箱的主要组成部分。一些书籍和论文讲述了如何在单元测试中使用模拟对象,描述的是模拟对象应该具备的能力以及如何使用他们。尽管如此,在没有任何基础的情况下开始创建模拟对象是相当困难的。我们希望能够自动完成这项任务。

有一些软件工具,像 EasyMock,2提供了自动帮助功能,但是它们可能很复杂而且也很难使用。此外,他们不是总能够与我们其他的开发工具兼容。但是利用现有的工具可以有一些创建模拟对象或是相当能力的简单方法。文章后面的部分将展示利用 Eclipse 平台做到这一点的一些方法。

从接口创建模拟对象

面向对象的设计专家建议我们对接口进行编程。如果我们这样做,设计会更加新颖、灵活并且对变化反应灵敏。我们来看这样一个对接口编程例子,我们使用 Java JDBC™ API 来操作数据库。我们将考虑基于 JDBC API Tutorial and Reference, Second Edition 一书中代码的简单例子。在关系数据库中有一个表格,表格中有 a, b, c 三列,有整数型、字符串型和浮点型三种数据类型。下面的方法,在一个被称为 DatabaseExample 的类中,使用数据库 Connection 对象,从数据库中读取记录并打印出数值。

code 1

为了给 readABC( ) 编写单元测试,我们需要一个 Connector 对象、一个 Statement 对象和一个 ResultSet 对象。Connector、 Statement 和 ResultSet 都是 java.sql 数据包中已设定的接口。特定的数据库系统提供这些接口的具体实现。readABC( ) 方法不需要依赖任何特定的数据库系统。我们的测试应该也不需要。事实上,我们可能也没有选定的数据库软件。既然测试里的方法写在接口中,我们可以为我们测试的每一个接口创建一个模拟对象。

让我们以被传递到方法之中的 Connection 对象为出发点。我们调用的唯一的 Connection 方法是 createStatement( )。但是我们的 Connection 必须完成定义在 Connection 接口中的所有方法。使用 Eclipse 创建这样一个对象很简单。我们按照以下步骤执行:

  1. 在 Eclipse 中创建一个完成 Connection 接口的新类(称它为 MockConection),检查为继承的抽象方法产生桩模块的选项(见图2)。Eclipse 为非 void 的方法产生带有默认返回值的新类。如果返回值是一个对象,默认为空值。
  2. 找到那些以测试里的方法命名的方法,并提供一个行为像真实对象那样的实现。
  3. 在模拟对象里添加方法,使得您的测试正确地初始化。

Figure 2

图2:从接口创建一个模拟对象类

code 2

为了实现我们的方法版本,我们需要一个对象来实现 Statement 接口。我们将按照上面的第一步创建一个默认值,称作 MockStatement。现在我们可以执行经过简单变换的方法:

code 3

既然在 MockConnection 类里我们不需要任何其他东西,那么我们不需要编写任何初始化代码。当我们以后为其他测试(包括测试代码,就像应用程序代码)使用类的时候我们可能需要添加这些代码。

MockStatement 必须为执行对数据库 SQL 查询提供行为。这意味着它必须返回到一个 ResultSet 对象。ResultSet 对象需要提供测试中的方法所使用的行为。这在上面步骤中的第三步有所体现。因此,让我们创建 MockResultSet 对象。

在我们使用第一个步骤创建默认对象之后,我们需要为 next( )、getInt( )、getString( ) 和 getFloat( )方法提供一个实现。让我们先从 next( )方法入手。Next( ) 只是将指针指向从查询返回的下一行。这意味着在 MockResultSet 里我们需要一个 collection 来实现重复操作。我们可以通过在 MockResultSet 里创建一个私有 Collection 实例来实现,并提供对其进行访问并在其中进行重复操作的方法。我们通过在类中添加下列代码开始。

code 4

现在我们需要创建一个 MockResultSet 实例,为其装上我们想要(期待)的从数据库查询返回的记录,并连接使用 MockResultSet 对象的 MockStatement 对象。

第一次测试,我们想确保我们可以返回记录的结果。因此我们将创建一个 MockResultSet 对象,为其装上记录,将它传递给 MockStatement,并将这个 MockStatement 传递给 Connector。我们的测试实例代码看上去像这样:

code 5

首先,我们在 MockConnection 中增加一个 setStatement。这只是当调用 createStatement( ) 方法时在要返回的连接中节省一个 Statement (MockStatement)。当执行测试时,我们在 readABC( )方法中的 while 循环一开始处得到一个空指针异常。问题是在 MockStatement 中 executeQuery( )方法并不返回 MockResultSet。让我们改变这一点。

code 6

无论我在模拟对象的什么地方改变桩模块来执行适当的活动以支持我们测试,我都按照惯例加入了注释"// MOCK"。现在当我运行测试时,没有成功,因为声明期望我输入的字符串并返回一个空的字符串(不是空值)。我们需要在 MockResultSet 中执行 next( )方法,然后执行从记录中获取数值的方法。next( ) 方法实施看上去像这样:

code 7

最后,我们执行缺少的 get...( )方法。下面的代码显示 getInt( )方法看上去如何。getString( ) 和 getFloat( )方法是相似的。

code

改变了三种方法之后,我们的测试用例运行并通过。现在我们可以开始添加更多的测试,并根据需要将代码添加到我们的模拟对象里。

收益又是什么呢?

在这一环节,值得探讨我们使用模拟对象得到的收益是什么以及花费的代价是什么。让我们先考虑收益。

  • 我们已经编写了检测应用程序代码的单元测试。应用程序代码使用数据库,我们可以不需要数据库就能运行它。
  • 我们的测试代码,特别是模拟对象,使我们能完全控制数据库结果。
  • 测试可以快速运行,不需要依赖真实的数据库,也不需要网络连接。
  • 已有的模拟对象类可以再度使用,稍做修改就可以用于未来的测试。

实现这些解决方案我们需要付出什么?

  • 我们需要创建三个模拟对象类。我们自动完成这一任务,不需要为基础执行编写任何代码。如果我们真的需要通过为每一种方法编写桩模块代码来执行类,那么我们早已独立编写出140多种桩模块方法!
  • 我们大约需要编写50行代码来真正执行我们在模拟对象类中所需的行为。

在我们看来收益远远超出成本。从接口创建模拟对象的过程简单易行,并能帮助我们开发有效的单元测试套件。这一过程与大多数现代化的交互式开发环境(IDEs)工作很好,例如 Eclipse。

如果没有接口如何处理?

设计接口是一个面向对象的极好实践,但是我们没办法总能得到遵循此种设计原则遗留代码。那么怎么办呢?如果我们拥有的代码在执行中使用一个具体的类该怎么办?有两种方法可以处理这一情况。按照上面的解决方案两种方法的成本大体相同。

从类中创建一个接口

如果您只有一个具体的类,那么您可以从类中创建一个接口,您的应用程序代码改写为接口,然后按照前面的描述继续进行。通过引入更多的抽象并对其编程,除了提供特别的类以外,这还提供了提高设计的额外收益。Eclipse 中的重构支持使您能够创建带有单一动作的接口。在 Package 视图中选择类,右击,然后选择 Refactor>Select Interface...。这种方法比选择接口作为开始在执行的工作量方面稍微多花些成本。在大多数交互式开发环境(IDEs)中创建接口很简单,但是如果您决定使用结果替换具体类中的所有源代码,在应用程序代码中改变的数量是十分大的。

创建一个模拟对象类作为具体类中的一个子类

这一解决方法并不像从类中创建一个接口的方法一样令人满意。创建一个子类,并仅仅重载您在测试方法中所使用的那些方法。尽管如此,如果您拥有调用超类构造器的构造器您必须小心一些,因为在您不能控制的外部对象上没有隐藏的依赖关系,例如数据库、网络连接等等。与上面的方法一样,这一点不太令人满意,因为创建接口的确能够再分解代码使其变得更加清楚。这种方法是当我不能改变测试中的代码时所使用的。这种方法所花费的成本与使用接口创建一个模拟对象类所花费的成本一样。

真的没有那么困难

我希望你们能够发现在单元测试中使用模拟对象的益处,也希望这篇简短的指南能够帮您了解如何简单地创建模拟对象。一开始,模拟对象看上去令人生畏。不可否认,一些执行是相当复杂的——超过他们所要求的。尽管如此,这里所展示的方法并不是最终的文字。有时您可能需要开发更复杂的模拟对象类,因为他们会带给您额外的收益。我的建议仅仅是抛转引玉,使用这里所讲述的一些方法并按照您的需要增加难度。使用其他工具或者那些预先打包好的模拟对象。一些模拟对象可以在模拟对象的网站4上中找到。

还有另外一个我正在研究的可能解决方案。我想这一方案有希望引导单元测试的新发展,这个方案是方面的一个应用,它能够模拟您的测试需要的能力。5我们将另行进行这一讨论。

参考资料

1 Tim Mackinnon、Steve Freeman 和 Philip Craig 撰写的 《Endo-testing: Unit testing with mock objects》,《Extreme Programming Examined》、Giancarlo Succi 和 Michele Marchesi 编著,Addison-Wesley 2001。

2http://www.easymock.org

3 White 等人,JDBC API Tutorial and Reference,第二版。Addison Wesley 1999。

4参阅http://www.mockobjects.com/

5参阅 AspectJ Cookbook 第 20 章,20.4节,Russ Miles, O'Reilly 2005。

参考资料

条评论

developerWorks: 登录

标有星(*)号的字段是必填字段。


需要一个 IBM ID?
忘记 IBM ID?


忘记密码?
更改您的密码

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件

 


在您首次登录 developerWorks 时,会为您创建一份个人概要。您的个人概要中的信息(您的姓名、国家/地区,以及公司名称)是公开显示的,而且会随着您发布的任何内容一起显示,除非您选择隐藏您的公司名称。您可以随时更新您的 IBM 帐户。

所有提交的信息确保安全。

选择您的昵称



当您初次登录到 developerWorks 时,将会为您创建一份概要信息,您需要指定一个昵称。您的昵称将和您在 developerWorks 发布的内容显示在一起。

昵称长度在 3 至 31 个字符之间。 您的昵称在 developerWorks 社区中必须是唯一的,并且出于隐私保护的原因,不能是您的电子邮件地址。

标有星(*)号的字段是必填字段。

(昵称长度在 3 至 31 个字符之间)

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件.

 


所有提交的信息确保安全。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Rational
ArticleID=184267
ArticleTitle=Rational Edge: 对复杂的单元测试使用模拟对象
publish-date=12142006