怎样使用Junit Framework进行单元测试的编写

怎样使用Junit Framework进行单元测试的编写
http://www-900.ibm.com/developerWorks/cn/java/l-junit/index.shtml
申文波 ([email protected])
艾昂科技上海公司
2002 年 7 月

随着Refactoring技术和XP软件工程技术的广泛推广,单元测试的作用在软件工程中变得越来越重要,而一个简明易学、适用广泛、高效稳定的单元测试框架则对成功的实施单元测试有着至关重要的作用。在java编程语句环境里,Junit Framework是一个已经被多数java程序员采用和实证的优秀的测试框架,但是多数没有尝试Junit Framework的程序员在学习如何Junit Framework来编写适应自己开发项目的单元测试时,依然觉得有一定的难度,这可能是因为Junit随框架代码和实用工具附带的用户指南和文档的着重点在于解释单元测试框架的设计方法以及简单的类使用说明,而对在特定的测试框架(Junit)下如何实施单元测试,如何在项目开发的过程中更新和维护已经存在的单元测试代码没有详细的解释。因此本文档就两个着重点对Junit所附带的文档进行进一步的补充和说明,使Junit能被更多的开发团队采用,让单元测试乃至Refactoring、XP技术更好在更多的开发团队中推广。

1. 单元测试的编写原则

Junit附带文档所列举的单元测试带有一定的迷惑性,因为几乎所有的示例单元都是针对某个对象的某个方法,似乎Junit的单元测试仅适用于类组织结构的静态约束,从而使初学者怀疑Junit下的单元测试所能带来的效果。因此我们需要重新定义如何确定有价值的单元测试以及如何编写这些单元测试、维护这些单元测试,从而让更多的程序员接受和熟悉Junit下的单元测试的编写。

在Junit单元测试框架的设计时,作者一共设定了三个总体目标,第一个是简化测试的编写,这种简化包括测试框架的学习和实际测试单元的编写;第二个是使测试单元保持持久性;第三个则是可以利用既有的测试来编写相关的测试。从这三个目标可以看出,单元测试框架的基本设计考虑依然是从我们现有的测试方式和方法出发,而只是使测试变得更加容易实施和扩展并保持持久性。因此编写单元测试的原则可以从我们通常使用的测试方法借鉴和利用。

2. 如何确定单元测试

在我们通常的测试中,一个单元测试一般针对于特定对象的一个特定特性,譬如,假定我们编写了一个针对特定数据库访问的连接池的类包实现,我们会建立以下的单元测试:

在连接池启动后,是否根据定义的规则在池中建立了相应数量的数据库连接
申请一个数据库连接,是否根据定义的规则从池中直接获得缓存连接的引用,还是建立新的连接
释放一个数据库连接后,连接是否根据定义的规则被池释放或者缓存以便以后使用
后台Housekeeping线程是否按照定义的规则释放已经过期的连接申请
如果连接有时间期限,后台Housekeeping线程是否定期释放已经过期的缓存连接
这儿只列出了部分的可能测试,但是从这个列表我们可以看出单元测试的粒度。一个单元测试基本是以一个对象的明确特性为基础,单元测试的过程应该限定在一个明确的线程范围内。根据上面所述,一个单元测试的测试过程非常类似于一个Use Case的定义,但是单元测试的粒度一般来说比Use Case的定义要小,这点是容易理解的,因为Use Case是以单独的事务单元为基础的,而单元测试是以一组聚合性很强的对象的特定特征为基础的,一般而言一个事务中会利用许多的系统特征来完成具体的软件需求。

从上面的分析我们可以得出,测试单元应该以一个对象的内部状态的转换为基本编写单元。一个软件系统就和一辆设计好的汽车一样,系统的状态是由同一时刻时系统内部的各个分立的部件的状态决定的,因此为了确定一个系统最终的行为符合我们起始的要求,我们首先需要保证系统内的各个部分的状态会符合我们的设计要求,所以我们的测试单元的重点应该放在确定对象的状态变换上。

然而需要注意的并不是所有的对象组特征都需要被编写成独立的测试单元,如何在对象组特征里筛选有价值的测试单元的原则在JUnitTest Infected: Programmers Love Writing Tests一文中得到了正确的描述,你应该在有可能引入错误的地方引入测试单元,通常这些地方存在于有特定边界条件、复杂算法以及需求变动比较频繁的代码逻辑中。除了这些特性需要被编写成独立的测试单元外,还有一些边界条件比较复杂的对象方法也应该被编写成独立的测试单元,这部分单元测试已经在Junit文档中被较好的描述和解释过了。

在基本确定了需要编写的单元测试,我们还应该问自己:编写好了这些测试,我们是否可以有把握地告诉自己,如果代码通过了这些单元测试,我们能认定程序的运行是正确的,符合需求的。如果我们不能非常的确定,就应该看看是否还有遗漏的需要编写的单元测试或者重新审视我们对软件需求的理解。通常来说,在开始使用单元测试的时候,更多的单元测试总是没有错的。

一旦我们确定了需要被编写的测试单元,接下来就应该

3. 如何编写单元测试

在XP下强调单元测试必须由类包的编写者负责编写,这个限定对于我们设定的测试目标是必须的。因为只有这样,测试才能保证对象的运行时态行为符合需求,而仅通过类接口的测试,我们只能确保对象符合静态约束,因此这就要求我们在测试的过程中,必须开放一定的内部数据结构,或者针对特定的运行行为建立适当的数据记录,并把这些数据暴露给特定的测试单元。这也就是说我们在编写单元测试时必须对相应的类包进行修改,这样的修改也发生在我们以前使用的测试方法中,因此以前的测试标记及其他一些测试技巧仍然可以在Junit测试中改进使用。

由于单元测试的总体目标是负责我们的软件在运行过程中的正确无误,因此在我们对一个对象编写单元测试的时候,我们不但需要保证类的静态约束符合我们的设计意图,而且需要保证对象在特定的条件下的运行状态符合我们的预先设定。还是拿数据库缓冲池的例子说明,一个缓冲池暴露给其他对象的是一组使用接口,其中包括对池的参数设定、池的初始化、池的销毁、从这个池里获得一个数据连接以及释放连接到池中,对其他对象而言随着各种条件的触发而引起池的内部状态的变化是不需要知道的,这一点也是符合封装原理的。但是池对象的状态变化,譬如:缓存的连接数在某些条件下会增长,一个连接在足够长的运行后需要被彻底释放从而使池的连接被更新等等,虽然外部对象不需要明确,但是却是程序运行正确的保证,所以我们的单元测试必须保证这些内部逻辑被正确的运行。

编译语言的测试和调试是很难对运行的逻辑过程进行跟踪的,但是我们知道,无论逻辑怎么运行,如果状态的转换符合我们的行为设定,那验证结果显然是正确的,因此在对一个对象进行单元测试的时候,我们需要对多数的状态转换进行分析和对照,从而验证对象的行为。状态是通过一系列的状态数据来描述的,因此编写单元测试首先分析出状态的变化过程(状态转换图对这个过程的描述非常清晰),然后根据状态的定义确定分析的状态数据,最后是提供这些内部的状态数据的访问。在数据库连接池的例子中,我们对池实现的对象DefaultConnectionProxy的状态变换进行分析后,我们决定把表征状态的OracleConnectionCacheImpl对象公开给测试类。参见示例一

示例一
/**
* 这个类简单的包装了oracle对数据连接缓冲池的实现。
*
*/
public class DefaultConnectionProxy extends ConnectionProxy {

private static final String name = "Default Connection Proxy";
private static final String description = "这个类简单的包装了oracle对数据连接缓冲池的实现。";
private static final String author = "Ion-Global.com";
private static final int major_version = 0;
private static final int minor_version = 9;
private static final boolean pooled = true;

private ConnectionBroker connectionBroker = null;
private Properties props;
private Properties propDescriptions;

private Object initLock = new Object();

// Test Code Begin...
/* 为了能够了解对象的状态变化,因此需要把表征对象内部状态变化的部分私有变量提供公共的访问接口
  (或者提供让同一个类包的访问接口),以便使测试单元可以有效地判断对象的状态转变,
   在本示例中对包装的OracleConnectionCacheImpl对象提供访问接口。
*/
OracleConnectionCacheImpl getConnectionCache() {
if (connectionBroker == null) {
throw new IllegalStateException("You need start the server first.");
}

return connectionBroker.getConnectionCache();
}


// Test Code End...

在公开内部状态数据後,我们就可以编写我们的测试单元了,单元测试的选择方法和选择尺度已经在本文前面章节进行了说明,
但是仍然需要注意的是,由于assert方法会抛出一个error,你应该在测试方法的最后集中用assert相关方法进行判断,
这样可以确保资源得到释放。

对数据库连接池的例子,我们可以建立测试类DefaultConnectionProxyTest,同时建立数个test case,如下

示例二
/**
* 这个类对示例一中的类进行简单的测试。
*
*/
public class DefaultConnectionProxyTest extends TestCase {
private DefaultConnectionProxy conProxy = null;
private OracleConnectionCacheImpl cacheImpl = null;
private Connection con = null;

/** 设置测试的fixture,建立必要的测试起始环境。
*/
protected void setUp() {
conProxy = new DefaultConnectionProxy();
conProxy.start();
cacheImpl = conProxy.getConnectionCache();
}

/** 对示例一中的对象进行服务启动后的状态测试,检查是否在服务启动后,
连接池的参数设置是否正确。
*/
public void testConnectionProxyStart() {
int minConnections = 0;
int maxConnections = 0;

assertNotNull(cacheImpl);

try{
minConnections = Integer.parseInt(PropertyManager.getProperty
("DefaultConnectionProxy.minConnections"));
maxConnections = Integer.parseInt(PropertyManager.getProperty
("DefaultConnectionProxy.maxConnections"));
} catch (Exception e) {
// ignore the exception
}

assertEquals(cacheImpl.getMinLimit(), minConnections);
assertEquals(cacheImpl.getMaxLimit(), maxConnections);
assertEquals(cacheImpl.getCacheSize(), minConnections);
}

/** 对示例一中的对象进行获取数据库连接的测试,看看是否可以获取有效的数据库连接,
并且看看获取连接后,连接池的状态是否按照既定的策略进行变化。由于assert方法抛出的是
error对象,因此尽可能把assert方法放置到方法的最后集体进行测试,这样在方法内打开的
资源,才能有效的被正确关闭。
*/
public void testGetConnection() {
int cacheSize = cacheImpl.getCacheSize();
int activeSize = cacheImpl.getActiveSize();
int cacheSizeAfter = 0;
int activeSizeAfter = 0;

con = conProxy.getConnection();

if (con != null) {
activeSizeAfter = cacheImpl.getActiveSize();
cacheSizeAfter = cacheImpl.getCacheSize();

try{
con.close();
} catch (SQLException e) {
}
} else {
assertNotNull(con);
}

/*如果连接池中的实际使用连接数小于缓存连接数,检查获取的新的数据连接是否
从缓存中获取,反之连接池是否建立新的连接
*/
if (cacheSize > activeSize){
assertEquals(activeSize + 1, activeSizeAfter);
assertEquals(cacheSize, cacheSizeAfter);
} else {
assertEquals(activeSize + 1, cacheSizeAfter);
}
}

/** 对示例一中的对象进行数据库连接释放的测试,看看连接释放后,连接池的
状态是否按照既定的策略进行变化。由于assert方法抛出的是error对象,因此尽可
能把assert方法放置到方法的最后集体进行测试,这样在方法内打开的
资源,才能有效的被正确关闭。
*/
public void testConnectionClose() {
int minConnections = cacheImpl.getMinLimit();
int cacheSize = 0;
int activeSize = 0;
int cacheSizeAfter = 0;
int activeSizeAfter = 0;

con = conProxy.getConnection();

if (con != null) {
cacheSize = cacheImpl.getCacheSize();
activeSize = cacheImpl.getActiveSize();

try{
con.close();
} catch (SQLException e) {
}

activeSizeAfter = cacheImpl.getActiveSize();
cacheSizeAfter = cacheImpl.getCacheSize();
} else {
assertNotNull(con);
}

assertEquals(activeSize, activeSizeAfter + 1);

/*如果连接池中的缓存连接数大于最少缓存连接数,检查释放数据连接后是否
缓存连接数比之前减少了一个,反之缓存连接数是否保持为最少缓存连接数
*/
if (cacheSize > minConnections){
assertEquals(cacheSize, cacheSizeAfter + 1);
} else {
assertEquals(cacheSize, minConnections);
}
}

/** 释放建立测试起始环境时的资源。
*/
protected void tearDown() {
cacheImpl = null;
conProxy.destroy();
}

public DefaultConnectionProxyTest(String name) {
super(name);
}

/** 你可以简单的运行这个类从而对类中所包含的测试单元进行测试。
*/
public static void main(String args[]) {
junit.textui.TestRunner.run(DefaultConnectionProxyTest.class);
}
}

当单元测试完成后,我们可以用Junit提供的TestSuite对象对测试单元进行组织,你可以决定测试的顺序,然后运行你的测试。

4. 如何维护单元测试

通过上面的描述,我们对如何确定和编写测试有了基本的了解,但是需求总是变化的,因此我们的单元测试也会根据需求的变化不断的演变。如果我们决定修改类的行为规则,可以明确的是,我们当然会对针对这个类的测试单元进行修改,以适应变化。但是如果对这个类仅有调用关系的类的行为定义没有变化则相应的单元测试仍然是可靠和充分的,同时如果包含行为变化的类的对象的状态定义与其没有直接的关系,测试单元仍然起效。这种结果也是封装原则的优势体现。

关于作者

申文波: 1973年出生,现于艾昂科技上海公司任资深技术顾问。在关系数据库对象建模方面有较长的工作经验,熟悉Java语言,目前从事的工作领域主要包括OOA、OOD和企业应用。您可以通过邮件[email protected]与他联系。




--------------------------------------------------------------------------------
此文章相关评论:

该文章有2个相关评论如下:(点这儿可以发表评论)

cinc 发表于: 2002/09/22 01:51pm

[这个贴子最后由cinc在 2002/09/22 01:56pm 编辑]

JUnit 是一个集成测试工具。能实现测试的自动化。
他写的是单元测试:软件工程里的白盒测试,就是测试某个类的某个方法的功能。
XP 中推崇的 test first design 就是基于以上的技术

如果你要写一段代码:
先用 junit 写测试,然后再写代码
写完代码,运行测试,测试失败
修改代码,运行测试,直到测试成功

如果以后对程序进行修改,优化 ( refactoring ),只要再运行测试代码
如果所有的测试都成功,则代码修改完成。

Java 下的 team 开发,一般采用 cvs(版本控制) + ant(项目管理) + junit(集成测试) 的模式:
每天早上上班,每个开发人员从 cvs server 获取一个整个项目的工作拷贝。
拿到自己的任务,先用 junit 写今天的任务的测试代码。
然后写今天任务的代码,运行测试,直到测试通过,任务完成
在下班前一两个小时,各个开发人员把任务提交到 cvs server
然后由主管对整个项目运行自动测试,哪个测试出错,就找相关人员修改,直到所有测试通过。下班。。。


先写测试,再写代码的好处:

从技术上强制你先考虑一个类的功能,也就是这个类提供给外部的接口,而不至于太早
陷入它的细节。这是面向对象提倡的一种设计原则。

好的测试其实就是一个好的文档,这个类使用者往往可以通过查看这个类的测试代码了
解它的功能。特别的,如果你拿到别人的一个程序,对他写测试是最好的了解这个程序
的功能的方法。 xp的原则是 make it simple,不是很推荐另外写文档,因为项目在开
发过程中往往处于变动中,如果在早期写文档,以后代码变动后还得同步文档,多了一
个工作,而且由于项目时间紧往往文档写的不全或与代码不一致,与其这样,不如不写。
而如果在项目结束后再写文档,开发人员往往已经忘记当时写代码时的种种考虑,况且
有下一个项目的压力,管理人员也不愿意再为旧的项目写文档。导致以后维护的问题。

没有人能保证需求不变动,以往项目往往对需求的变动大为头疼,害怕这个改动会带来
其他地方的错误。为此,除了设计好的结构以分割项目外(松耦合),但如果有了测试,
并已经建立了一个好的测试框架,对于需求的变动,修改完代码后,只要重新运行测试
代码,如果测试通过,也就保证了修改的成功,如果测试中出现错误,也会马上发现错
在哪里。修改相应的部分,再运行测试,直至测试完全通过。

软件公司里往往存在开发部门和测试部门之间的矛盾:由于开发和测试分为两个部门,
多了一层沟通的成本和时间,沟通往往会产生错误的发生。而且极易形成一个怪圈:开
发人员为了赶任务,写了烂烂的代码,就把它扔给测试人员,然后写其他的任务,测试
当然是失败的,又把代码拿回去重写,而且在国内往往一个软件公司技术最差的部门就
是测试部门(好的人都跑去写代码了),测试就成了一个很头疼的问题。这种怪圈的根
源是责任不清,根据 xp 中的规定:写这个代码的人必须为自己的代码写测试,而且只
有测试通过,才算完成这个任务(这里的测试包括所有的测试,如果测试时发现由于你
的程序导致别的模块的测试失败,你有责任通知相关人员修改直至集成测试通过),这
样就可以避免这类问题的发生。



你可能感兴趣的:(框架,单元测试,JUnit,软件测试,XP)