软件开发习惯中一个细微更改都可能会对软件质量产生巨大改进将单元测试合并到开发过程中然后从长远角度来看它可以节省多少时间和精力本文通过使用代码样本说明了单元测试的种种好处特别是使用 Ant 和 JUnit 带来的各种方便
测试是大型开发过程中的基本原则之一在任何职业中验证都是一个重要部分医生要通过验血来确诊波音公司在研制 的过程中对飞机的每个组件都进行了精心测试为什么软件开发就应该例外呢?
以前由于在应用程序中将 GUI 和商业逻辑紧密联系在一起这就限制了创建自动测试的能力当我们学会通过抽象层将商业逻辑从界面中分离出来时各个单独代码模块的自动测试就替代了通过 GUI 进行的手工测试
现在集成开发环境 (IDE) 能在您输入代码的同时显示错误对于在类中快速查找方法具有智能探测功能可以利用语法结构生成彩色代码而且具有许多其它功能因此在编译更改过的代码之前您已经全盘考虑了将构建的类但您是否考虑过这样的修改会破坏某些功能呢?
每个开发者都碰到过更改臭虫代码修改过程可能会引入臭虫而如果通过用户界面手工测试代码的话在编译完成之前是不会发现它的然后您就要花费几天的时间追蹤由更改所引起的错误最近在我做的一个项目中当我把后端数据库由 Informix 更改到 Oracle 时就遇到了这种情况大部分更改都十分顺利但由于数据库层或使用数据库层的系统缺少单元测试从而导致将大量时间花费在尝试解决更改臭虫上我花了两天的时间查到别人代码中的一个数据库语法更改(当然那个人仍是我的朋友)
尽管测试有许多好处但一般的程序员对测试都不太感兴趣开始时我也没有您听到过多少次它编译了所以它一定能用这种言论?但我思故我在这种原则并 不 适用于高质量软件要鼓励程序员测试他们的代码过程必须简单无痛
本文从某人学习用 Java 语言编程时所写的一个简单的类开始然后我会告诉您我是如何为这个类编写单元测试以及在编写完它以后又是如何将单元测试添加到构建过程中的最后我们将看到将臭虫引入代码时发生的情况
从一个典型类开始
第一个典型的 Java 程序一般都包含一个打印 Hello World 的 main() 在清单 中我创建了一个 HelloWorld 对象的实例并调用 sayHello() 方法该方法会打印这句习惯说法
清单
我的第一个 Java 应用程序
Hello world
/* * HelloWorldjava * My first java program */ class HelloWorld { /** * Print Hello World */ void sayHello() { Systemoutprintln(Hello World); } /** * Test */ public static void main( String[] args ) { HelloWorld world = new HelloWorld(); worldsayHello(); } }
main() 方法是我的测试哦噢!我将代码文档测试和样本代码包含在了一个模块中保佑 Java!但随着程序越变越大这种开发方法很快就开始显现出了缺陷
- 混乱
类接口越大 main() 就越大类可能仅仅因为正常的测试而变得非常庞大
- 代码膨胀
由于加入了测试所以产品代码比所需要的要大但我不想交付测试而只想交付产品
- 测试不可靠
既然 main() 是代码的一部分 main() 就对其他开发者通过类接口无法访问的私有成员和方法享有访问权出于这个原因这种测试方法很容易出错
- 很难自动测试
要进行自动测试我仍然必须创建另一程序来将参数传递给 main()
类开发
对我来说类开发是从编写 main() 方法开始的我在编写 main() 的时候就定义类和类的用法然后实现接口它的一些明显的缺陷也开始显现出来一个缺陷是我传递给 main() 来执行测试的参数个数其次 main() 本身在进行调用子方法设置代码等操作时变得很混乱有时 main() 会比类实现的其余部分还要大
更简单的过程
我原来的做法有一些很明显的缺陷因此让我们看看有什么别的方法可以使问题简化我仍然通过接口设计代码并给出应用示例正如原来的 main() 一样不同的是我将代码放到了另一个单独的类中而这个类恰好是我的单元测试这种技术有以下几点好处
- 设计类的一种机制
因为是通过接口进行开发所以不太可能利用类的内部功能但因为我是目标类的开发者我有到其内部工作的窗口所以测试并不是个真正的黑箱仅凭这一点就足够推断出需要开发者本人在编写目标类的同时负责测试的开发而不是由其他任何人代劳
- 类用法的示例
通过将示例从实现中分离出来开发者可以更快地提高速度而且再不用在源代码上纠缠不清这种分离还有助于防止开发者利用类的内部功能因为这些功能将来可能已经不存在了
- 没有类混乱的 main()
我不再受到 main() 的限制了以前我得将多个参数传递给 main() 来测试不同的配置现在我可以创建许多单独的测试类每一个都维护各自的设置代码
接下来我们将这个单独的单元测试对象放入构建过程中这样我们就可以提供自动确认过程的方法
- 确保所做的任何更改都不会对其他人产生不利影响
- 我们在进行源码控制之前就可以测试代码而无需等待汇编测试或在夜晚进行的构建测试这有助于尽早捕捉到臭虫从而降低产生高质量代码的成本
- 通过提供增量测试过程我们提供了更好的实现过程如同 IDE 帮助我们在输入时捕捉到语法或编译臭虫一样增量单元测试也帮助我们在构建时捕捉到代码更改臭虫
使用 JUnit 自动化单元测试
要使测试自动化您需要一个测试框架您可以自己开发或购买也可以使用某些开放源代码工具例如 JUnit我选择 JUnit 出于以下几个原因
- 不需要编写自己的框架
- 它是开放源代码因此不需要购买框架
- 开放源代码社区中的其他开发者会使用它因此可以找到许多示例
- 它可以让我将测试代码与产品代码分开
- 它易于集成到我的构建过程中
测试布局
图 显示了使用样本 TestSuite 的 JUnit TestSuite 布局每个测试都由若干单独的测试案例构成每个测试案例都是一个单独的类它扩展了 TestClass 类并包含了我的测试代码即那些曾在 main() 中出现的代码在该例中我向 TestSuite 添加了两个测试一个是 SkeletonTest我将它用作所有新类和 HelloWorld 类的起点
图 TestSuite 布局
测试类 HelloWorldTestjava
按照约定测试类的名称中包含我所测试的类的名称但将 Test 附加到结尾在本例中我们的测试类是 HelloWorldTestjava 我复制了 SkeletonTest 中的代码并添加了 testSayHello() 来测试 sayHello() 请注意 HelloWorldTest 扩展了 TestCaseJUnit 框架提供了 assert 和 assertEquals 方法我们可以使用这些方法来进行验证 HelloWorldTestjava 显示在清单 中
清单
HelloWorldTest
java
package testcomcompany; import comcompanyHelloWorld; import junitframeworkTestCase; import junitframeworkAssertionFailedError; /** * JUnit testcases for HelloWorld */ public class HelloWorldTest extends TestCase { public HelloWorldTest(String name) { super(name); } public static void main(String args[]) { junittextuiTestRunnerrun(HelloWorldTestclass); } public void testSayHello() { HelloWorld world = new HelloWorld(); assert( world!=null ); assertEquals(Hello World worldsayHello() ); } }
testSayHello() 看上去和 HelloWorldjava 中原来的 main 方法类似但有一个主要的不同之处它不是执行 Systemoutprintln 并显示结果而是添加了一个 assertEquals() 方法如果两个值不同 assertEquals 将打印出两个输入的值您可能已经注意到这个方法不起作用!HelloWorld 中的 sayHello() 方法不返回字符串如果我先写过测试就会捕捉到这一点我将 Hello World 字符串与输出流联结起来这样按照清单 中显示的那样重写了 HelloWorld去掉 main() 并更改了 sayHello() 的返回类型
清单
Hello world 测试案例
package comcompany; public class HelloWorld { public String sayHello() { return Hello World; } }
如果我保留了 main() 并修改了联系代码看上去如下 public static void main( String[] args ) { HelloWorld world = new HelloWorld(); Systemoutprintln(worldsayHello()); }
新的 main() 与我测试程序中的 testSayHello() 非常相似是的它看上去不象是一个现实世界中的问题(这是人为示例的问题)但它说明了问题在单独的应用程序中编写 main() 可以改进您的设计同时帮助您设计测试现在我们已经创建了一个测试类让我们使用 Ant 来将它集成到构建中
使用 Ant 将测试集成到构建中
Jakarta Project 将 Ant 工具说成不带 make 缺点的 makeAnt 正在成为开放源代码世界中实际上的标准原因很简单Ant 是使用 Java 语言编写的这种语言可以让构建过程在多种平台上使用这种特性简化了在不同 OS 平台之间的程序员的合作而合作是开放源代码社区的一种需要您可以在自己选择的平台上进行开发 和 构建Ant 的特性包括
- 类可扩展性 Java 类可用于扩展构建特性而不必使用基于 shell 的命令
- 开放源代码 因为 Ant 是开放源代码因此类扩展示例很充足我发现通过示例来学习非常棒
- XML 可配置 Ant 不仅是基于 Java 的它还使用 XML 文件配置构建过程假设构建实际上是分层的那么使用 XML 描述 make 过程就是其逻辑层另外如果您了解 XML要学习如何配置构建就更简单一些
图 简要介绍了一个配置文件配置文件由目标树构成每个目标都包含了要执行的任务其中任务就是可以执行的代码在本例中 mkdir 是目标 compile 的任务 mkdir 是建立在 Ant 中的一个任务用于创建目录 Ant 带有一套健全的内置任务您也可以通过扩展 Ant 任务类来添加自己的功能
每个目标都有唯一的名称和可选的相关性目标相关性需要在执行目标任务列表之前执行例如图 所示在执行 compile 目标中的任务之前需要先运行 JUNIT 目标这种类型的配置可以让您在一个配置中有多个树
图 Ant XML 构建图
与经典 make 实用程序的相似性是非常显着的这是理所当然的因为 make 就是 make但也要记住有一些差异通过 Java 实现的跨平台和可扩展性通过 XML 实现的可配置还有开放源代码
下载和安装 Ant
首先下载 Ant(请参阅参考资料)将 Ant 解压缩到 tools 目录再将 Ant bin 目录添加到路径中(在我的机器上是 e:\tools\ant\bin )设置 ANT_HOME 环境变量在 NT 中这意味着进入系统属性然后以带有值的变量形式添加 ANT_HOMEANT_HOME 应该设置为 Ant 根目录即包含 bin 和 lib 目录的目录(对我来说是 e:\tools\ant )确保 JAVA_HOME 环境变量设置为安装了 JDK 的目录Ant 文档有关于安装的详细信息
下载和安装 JUnit
下载 JUnit (请参阅参考资料)解开 junitzip 并将 junitjar 添加到 CLASSPATH如果将 junitzip 解包到类路径中可以通过运行以下命令来测试安装 java junittextuiTestRunner junitsamplesAllTests
定义目录结构
在开始我们的构建和测试过程之前需要一个项目布局图 显示了我的样本项目的布局下面描述了布局的目录结构
- build 类文件的临时构建位置构建过程将创建这个目录
- src 源代码的位置 Src 被分为 test 文件夹和 main 文件夹前者用于所有的测试代码而后者包含可交付的代码将测试代码与主要代码分离提供了几点特性首先使主要代码中的混乱减少其次它允许包对齐我就热衷与将类和与其相关的包放置在一起测试就应该和测试在一起它还有助于分发过程因为你不可能打算将单元测试分发给客户
在实际中我们有多个目录例如 distribution 和 documentation 我们还会在 main 下有多个用于包的目录例如 comcompanyutil
因为目录结构经常变动所以在 buildxml 中有这些变动的全局字符串常数是很重要的
图 项目布局图
Ant 构建配置文件示例
下一步我们要创建配置文件清单 显示了一个 Ant 构建文件示例构建文件中的关键就是名为 runtests 的目标这个目标进行分支判断并运行外部程序其中外部程序是前面已安装的 junittextuiTestRunner 我们指定要使用语句 testcomcompanyAllJUnitTests 来运行哪个测试套件
清单
构建文件示例
<property name=appname value=sample /> <property name=builddir value=build/classes /> <target name=JUNIT> <available property=junitpresent classname=junitframeworkTestCase /> </target> <target name=compile depends=JUNIT> <mkdir dir=${builddir}/> <javac srcdir=src/main/ destdir=${builddir} > <include name=**/*java/> </javac> </target> <target name=jar depends=compile> <mkdir dir=build/lib/> <jar jarfile=build/lib/${appname}jar basedir=${builddir} includes=com/**/> </target> <target name=compiletests depends=jar> <mkdir dir=build/testcases/> <javac srcdir=src/test destdir=build/testcases> <classpath> <pathelement location=build/lib/${appname}jar /> <pathelement path= /> </classpath> <include name=**/*java/> </javac> </target> <target name=runtests depends=compiletests if=junitpresent> <java fork=yes classname=junittextuiTestRunner taskname=junit failonerror=true> <arg value=testcomcompanyAllJUnitTests/> <classpath> <pathelement location=build/lib/${appname}jar /> <pathelement location=build/testcases /> <pathelement path= /> <pathelement path=${javaclasspath} /> </classpath> </java> </target> </project>
运行 Ant 构建示例
开发过程中的下一步是运行将创建和测试 HelloWorld 类的构建清单 显示了构建的结果其中包括了各个目标部分最酷的那部分是 runtests 输出语句它告诉我们整个测试套件都正确运行了
我在图 和图 中显示了 JUnit GUI其中所要做的就是将 runtest 目标从 junittextuiTestRunner 改为 junituiTestRunner 当您使用 JUnit 的 GUI 部分时您必须选择退出按钮来继续构建过程如果使用 Junit GUI 构建包那么它将更难与大型的构建过程相集成另外文本输出也与构建过程更一致并可以定向输出到一个用于主构建记录的文本文件这对于每天晚上都要进行的构建非常合适
清单
构建输出示例
E:\projects\sample>ant runtests Searching for buildxml Buildfile: E:\projects\sample\buildxml JUNIT: compile: [mkdir] Created dir: E:\projects\sample\build\classes [javac] Compiling source file to E:\projects\sample\build\classes jar: [mkdir] Created dir: E:\projects\sample\build\lib [jar] Building jar: E:\projects\sample\build\lib\samplejar compiletests: [mkdir] Created dir: E:\projects\sample\build\testcases [javac] Compiling source files to E:\projects\sample\build\testcases runtests: [junit] [junit] Time: [junit] [junit] OK ( tests) [junit] BUILD SUCCESSFUL Total time: second
图 JUnit GUI 测试成功
图 JUnit GUI 测试失败
了解测试的工作原理
让我们搞点破坏然后看看会发生什么事夜深了我们决定把 Hello World 变成一个静态字符串在更改期间我们 不小心 打错了字母将 o 变成了 如清单 所示
清单
Hello world 类更改
package comcompany; public class HelloWorld { private final static String HELLO_WORLD = Hell World; public String sayHello() { return HELLO_WORLD; } }
在构建包时我们看到了错误清单 显示了 runtest 中的错误它显示了失败的测试类和测试方法并说明了为什么会失败我们返回到代码中改正错误后离开
清单
构建错误示例
E:\projects\sample>ant runtests Searching for buildxml Buildfile: E:\projects\sample\buildxml JUNIT: compile: jar: compiletests: runtests: [junit] F [junit] Time: [junit] [junit] FAILURES!!! [junit] Test Results: [junit] Run: Failures: Errors: [junit] There was failure: [junit] ) testSayHello(testcomcompanyHelloWorldTest) expected:<Hello World> but was:<Hell World> [junit] BUILD FAILED E:\projects\sample\buildxml:: Java returned: Total time: seconds
并非完全无痛
新的过程并不是完全无痛的为使单元测试成为开发的一部分您必须采取以下几个步骤
- 下载和安装 JUnit
- 下载和安装 Ant
- 为构建创建单独的结构
- 实现与主类分开的测试类
- 学习 Ant 构建过程
但好处远远超过了痛苦通过使单元测试成为开发过程的一部分您可以
- 自动验证以捕捉更改臭虫
- 从接口角度设计类
- 提供干净的示例
- 在发行包中避免代码混乱和类膨胀
实现 x
保证产品的质量要花费很多钱但如果质量有缺陷花费的钱就更多如何才能使所花的钱获得最大价值来保证产品质量呢?
- 评审设计和代码 评审可以达到的效果是单纯测试的一半
- 通过单元测试来确认模块可以使用 尽管测试早就存在但随着开发实践的不断发展单元测试逐渐成为日常开发过程的一个部分
在我 年的开发生涯里为 emageoncom 工作是最重要的部分之一在 emageoncom 时设计评审代码评审和单元测试是每天都要做的事这种日常开发习惯造就了最高质量的产品软件在客户地点第一年的当机次数为零是一个真正的 x 产品单元测试就象刷牙您不一定要做但如果做了生活质量就更好
参考资料
- 您可以参阅本文在 developerWorks 全球站点上的 英文原文
- 下载在本文中引用的 示例代码
- 从 Apache 网站下载 Ant如需 Ant 文档FAQ 和其他下载请访问 Jakarta 项目的Ant 主页
- JUnit 主页 提供了额外的测试示例文档文章和 FAQ您可以从 wwwxprogrammingcom 下载 JUnit
- Kent Beck 所写的 简单的 Smalltalk 测试(Simple Smalltalk Testing) 讨论了一个简单的测试策略和支持它的框架
- 请参阅其它开发者的有关单元测试的评论 (comments on unit testing)
- 要了解其它有用的开发习惯请访问 终极编程主页 (Extreme Programming Home page)
关于作者 Malcolm G Davis 拥有自己的咨询公司并任公司的总裁该公司位于美国阿拉巴马州的伯明翰 (Birmingham)他把自己看做是个 Java 传道者在工作之余他喜欢跑步以及和他的孩子们一起玩耍您可以通过 malcolm@nuearthcom 与 Malcolm 联系