前言
考察目前关于单元测试和JUnit的文章,要么是介绍单元测试的理论,要么是通过一个简单的HelloWorld例子介绍工具的使用。这样很容易使读者在实际应用中无从下手。因为只有工具而没有理论的指导,将严重消弱了工具的作用,最终只能是沙滩建楼,达不到预期的目标;只有理论而没有工具的支持,也使得理论难有很好的着力点,最终使理论流于空泛。本文试图通过先讲解单元测试理论,进而将这些理论结合到JUnit的使用当中,最后通过对一个实用的、可以重用的时间操作类采用JUnit进行单元测试来完整阐述单元测试的思想、方法、以及工具的使用。作者相信,只有通过这样,才能让读者真正把单元测试做好。
1.简介
1.1. 为什么要进行单元测试
一个特定的开发组织或软件应用系统的测试水平取决于对那些未发现的Bug的潜在后果的重视程度。这种后果一方面常常会被软件的开发人员所忽视,而另一方面却有可能损害组织的信誉,并且会导致对未来的市场产生负面的影响。相反地,一个可靠的软件系统的良好的声誉将有助于一个开发组织获取未来的市场。
很多研究成果表明,无论什么时候作出修改都要进行完整的回归测试,在生命周期中尽早地对软件产品进行测试将使效率和质量得到最好的保证。Bug发现得越晚,修改它所需的费用就越高,因此从经济角度来看,应该尽可能早的查找和修改Bug。在修改费用变得过高之前,单元测试是一个在早期抓住Bug的机会。
相比后阶段的测试,单元测试的创建更简单、维护更容易,并且可以更方便的进行重复。 从全程的费用来考虑,相比起那些复杂且旷日持久的集成测试,或是不稳定的软件系统来说,
单元测试所需的费用是很低的。研究显示高达50%的维护工作量被花在那些总是会有的Bug的修改上面。如果这些Bug在开发阶段被排除掉的话,那么工作量就可以节省下来。当考虑到软件维护费用可能会比最初的开发费用高出数倍的时候,这种潜在的对50%软件维护费用的节省将对整个软件生命周期费用产生重大的影响。
1.2. 什么是单元测试
单元测试是对最小的可测试软件元素(单元)实施的测试,它所测试的内容包括内部结构(如逻辑和数据流)以及单元的功能和可观测的行为。这里的单元不一定是指一个具体的函数或一个类的方法,“单元”是:
(1)可测试的、最小的、不可再分的程序模块。
(2)有明确的功能、规格定义。
(3)有明确的接口定义,清晰地与同一程序的其他单元划分开来。
在具体实现时,单元测试也可能对应的是多个程序文件中的一组函数。在一种传统的结构化编程语言中,比如C,要进行测试的单元一般是函数或子过程。在象C++这样的面向对象的语言中,要进行测试的基本单元是类。单元测试的原则同样被扩展到第四代语言(4GL)的开发中,在这里基本单元被典型地划分为一个菜单或显示界面。
1.3. 单元测试的一般方法
单元测试的方法一般分为两类:白盒方法和黑盒方法。白盒方法通常是分析单元内部结构后通过对单元输入输出的用例构造,达到单元内程序路径的最大覆盖,尽量保证单元内部程序运行路径处理正确,它侧重于单元内部结构的测试,依赖于对单元实施情况的了解。
黑盒方法通过对单元输入输出的用例构造验证单元的特性和行为,侧重于核实单元的可观测行为和功能,并不依赖于对单元实施情况的了解。进行单元测试必须综合使用上述两个方法,否则,单元测试很可能就是不成功、不完整和不彻底的。
1.4. 单元测试的目标
单元测试要达到的目标,总体来说就是保证单元内部的处理是正确的、没有遗漏和多余功能。细分而言,单元测试要达到以下几个目标:
(1)信息能否正确地流入和流出单元。
(2)在单元工作过程中,其内部数据能否保持其完整性,包括内部数据的形式、内容及相互关系不发生错误,也包括全局变量在单元中的处理和影响。
(3)在为限制数据加工而设置的边界处,能否正确工作。
(4)单元的运行能否做到满足特定的逻辑覆盖。
(5)单元中发生了错误,其中的出错处理措施是否有效。
1.5. 为什么要使用JUnit进行单元测试
1.5.1. 什么是JUnit
JUnit就是对程序代码进行单元测试的一种Java框架。通过每次修改程序之后测试代码,程序员就可以保证代码的的少量变动不会破坏整个系统。官方对JUnit的定义是“JUnit
is a simple framework to write repeatable tests.”。
1.5.2. 自己编写测试框架的弊病
自己编写测试框架进行单元测试一般有两个方法。第一种方法是在要测试的类的main()方法中编写测试代码。随着程序越变越大,这种开发方法很快就开始显现出了缺陷:
(1)混乱。类接口越大,main() 就越大。类可能仅仅因为正常的测试就变得非常庞大。
(2)代码膨胀。由于加入了测试,所以产品代码比所需要的要大。
(3)测试不可靠。main() 是代码的一部分,main() 就对其他开发者通过类接口无法访问的私有成员和方法享有访问权。出于这个原因,这种测试方法很容易出错。
(4)很难自动测试。要进行自动测试,必须创建另一程序来将参数传递给 main()。第二种方法是编写一个测试类框架,它虽然能够克服上个方法的缺陷,但增加了开发组织维护这个测试类框架的工作量,为立即大规模的重用设置障碍。而且,由于这个测试框架是内部开发的,存在着与业界难于交流和沟通的弊病。
1.5.3. JUnit的优势
(1)需要编写自己的框架。
(2)它是开放源代码,因此不需要购买框架。
(3)开放源代码社区中的其他开发者会使用它,因此可以找到许多示例。
(4)可以将测试代码与产品代码分开。
(5)易于集成到构建过程中。
2. 单元测试设计
2.1. 单元测试的一般过程
单元测试过程分为计划、设计、实现、执行、评估等几个步骤,各步骤的任务如下:
2.1.1. 计划
单元测试计划需明确如下目标:
(1)明确单元测试的测试对象,确定测试需求及测试通过的标准,明确活动的输出。
(2)明确测试方法和需要运行的工具需求。
(3)对工作量进行估计,确定测试所用资源(包括人力资源和设备资源),创建测试任务的时间表,必要时需将一个单元测试任务分解成更细化的子任务进行明确。
(4)对测试风险进行分析,制定相应的应急措施。
(5)明确测试优先级,制定测试取舍策略。
(6)输出单元测试计划文档。
2.1.2. 设计
单元测试的设计主要是完成方案和模型的确认,包括如下几方面内容:
(1)测试需求的进一步细化,必要时需追溯到详细设计文档中的单元设计目标。
(2)设计单元测试模型,包括与模型相关的工具的选用。
(3)制定测试方案,包括模型的设计和实现、定义测试规程和用例的实现和组织。
(4)输出单元测试方案文档。
2.1.3. 实现
单元测试实现主要是针对用例的实现,包括如下几个方面:
(1)参考测试模型和测试方案,制定具体的测试用例,创建可重用的测试脚本。
(2)输出单元用例文档。
2.1.4. 执行
根据单元测试的方案、用例对单元进行测试,验证测试的结果并记录测试过程中出现的缺陷,主要保留执行过程数据以备问题定位的回归对比。
2.1.5. 评估
对单元测试的结果进行评估,主要有如下几个方面:
(1)实际测试过程的记录,描述与计划的差异和原因,包括补充或裁剪的测试项目清单。
(2)对测试过程完备性以及被测单元质量的评价,包括用例执行情况清单和汇总分析。
(3)主要从需求覆盖和代码覆盖的角度进行测试完备性的评估。
(4)遗留问题记录和可能的分析。
(5)输出单元测试报告。
2.2. 单元测试用例设计方法
测试用例的设计在单元测试中占有非常重要的地位,测试用例设计的好坏直接影响到测试的效果。确定测试用例之所以很重要,原因有以下几方面:
(1)测试用例构成了设计和制定测试过程的基础。
(2)测试的“深度”与测试用例的数量成比例。由于每个测试用例反映不同的场景、条件或经由产品的事件流,因而,随着测试用例数量的增加,对产品质量和测试流程也就越有信心。判断测试是否完全的一个主要评测方法是基于需求的覆盖,而这又是以确定、实施和/或执行的测试用例的数量为依据的。
(3)测试工作量与测试用例的数量成比例。根据全面且细化的测试用例,可以更准确地估计测试周期各连续阶段的时间安排。
(4)测试设计和开发的类型以及所需的资源主要都受控于测试用例。测试用例通常根据它们所关联关系的测试类型或测试需求来分类,而且将随类型和需求进行相应地改变。
最佳方案是为每个测试需求至少编制两个测试用例:
(1)一个测试用例用于证明该需求已经满足,通常称作正面测试用例。
(2)另一个测试用例反映某个无法接受、反常或意外的条件或数据,用于论证只有在所需条件下才能够满足该需求,这个测试用例称作负面测试用例。
单元测试既可以是白盒测试也可以是黑盒测试。白盒测试主要是检查程序的内部结构、逻辑、循环和路径。其常用测试用例设计方法有:逻辑覆盖和基本路径测试。根据覆盖测试的目标不同,逻辑覆盖又可分为:语句覆盖,判定覆盖,判定-条件覆盖,条件组合覆盖及路径覆盖等。白盒测试用例设计还可用到:状态转移测试、数据定义-使用测试、等价类划分、边界值分析等。黑盒测试注重对程序功能方面的要求,它只用到程序的规格说明,没有用到程序的内部结构。其常用测试用例方法有:规范(规格)导出、等价类划分、边界值分析法、错误推测法和因果图分析方法。下面将简要介绍各个方法,更详细的说明请读者自行参考相关的测试理论书籍。
2.2.1. 语句覆盖
语句覆盖就是设计若干个测试用例,运行所测程序,使得每一可执行语句至少执行一次。
2.2.2. 判定覆盖
判定覆盖就是设计若干个测试用例,运行所测程序,使得程序中每个判断的取TURE分支和取FALSE分支至少经历一次。
2.2.3. 条件覆盖
条件覆盖就是设计若干个测试用例,运行所测程序,使得程序中每个判断的每个条件的可能取值至少执行一次。
2.2.4. 判定-条件覆盖
判定-条件覆盖就是设计足够的测试用例,使得判断中每个条件的所有可能取值至少执行一次,同时每个判断的所有可能判断结果至少执行一次。也就是说要求各个判断的所有可能的条件取值组合至少执行一次。
2.2.5. 条件组合覆盖
条件组合覆盖就是设计足够的测试用例,运行所测程序,使得每个判断得所有可能得条件取值组合至少执行一次。
2.2.6. 路径覆盖
路径测试就是设计足够的测试用例,覆盖程序中所有可能的路径。
2.2.7. 规范(规格)导出法
规范导出法是根据相关的规范描述来设计测试用例。每一个测试用例用来测试一个或多个规范陈述语句。一个比较实际的方法是根据陈述规范所用语句的顺序来相应地为被测单元设计测试用例。
2.2.8. 状态转移测试法
对于那些以状态机作为模型或设计为状态机的软件,状态转移测试是合适的测试方法。测试用例通过能导致状态迁移的事件来测试状态之间的转换。
2.2.9. 数据定义-使用测试法
数据定义是指数据项被赋值的地方,数据使用是指数据项被读或使用的地方。目的是设计测试用例以驱动执行通过数据定义于使用之间的路径。
3. 使用JUnit进行单元测试的一般步骤
3.1. 获得Junit
下载得到JUnit的安装软件包。
3.2. 安装JUnit安装JUnit只需要很简单的两个步骤,下面是安装Junit的步骤:
(1)解开DownLoad下来的junit.zip文件。
(2)增加junit.jar到classpath中。例如,set classpath = %classpath%;
INSTALL_DIR\Junit3.7\junit.jar经过这两步,就可以开始使用JUnit了。
3.3. 使用JUnit编写测试代码的一般步骤
使用JUnit编写测试代码的一般步骤是:
(1)定义测试类名称,一般是将要测试的类名后附加Test。
(2)引入JUnit框架包。import junit.framework.*。
(3)测试类继承JUnit的TestCase类。
(4)实现类的构造方法,可以在构造方法中简单的调用super(name)即可。
(5)实现类的main()方法,在main()方法中简单调用junit.textui.TestRunner.run(DateUtilTest.class)来指定执行测试类。
(6)重载setUp()和tearDown()方法,setUp()方法用于执行每个测试用例时进行环境的初始化工作(比如打开数据库连接),tearDown()方法用于执行每个测试用例后清除环境(比如关闭数据库连接)。
(7)编写每个测试用例,一般是要测试的方法前附加test。
完整的代码框架如下所示:
import junit.framework.*; public class DateUtilTest extends TestCase
{ /** * 构造函数 */ public DateUtilTest(String name) { super(name); }
/** * 主方法 */ public static void main(String args[]) { junit.textui.TestRunner.run(DateUtilTest.class); }
/** * 测试前的初始化 */ protected void setUp() { } /** * 清除测试环境 */ protected void tearDown(){ }
/** * 测试用例1 */ public void testGetDateFormat() { } } |
4. 使用JUnit进行单元测试Java应用一例
4.1. 定义接口
按照JUnit的思想,“先有测试代码,后有实现代码”,在编写代码之前,首先应该确定接口。本样例的接口定义如下:
/** * <p>Title: 时间和日期的工具类</p> *
<p>Description: DateUtil类包含了标准的时间和日期格式,
以及这些格式在字符串及日期之间转 换的方法</p> *
<p>Copyright: Copyright (c) 2002</p> * <p>Company: </p>
* @author kzx * @version 1.0 */ import java.text.*; import java.util.*; public abstract class DateUtil
{ /** * 标准日期格式
*/ private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("MM/dd/yyyy");
/** * 标准时间格式
*/ private static final SimpleDateFormat DATE_TIME_FORMAT = new SimpleDateFormat("MM/dd/yyyy HH:mm");
/** * 带时分秒的标准时间格式
*/ private static final SimpleDateFormat DATE_TIME_EXTENDED_FORMAT =
new SimpleDateFormat("MM/dd/yyyy HH:mm:ss");
/** * ORA标准日期格式
*/ private static final SimpleDateFormat ORA_DATE_FORMAT = new SimpleDateFormat("yyyyMMdd");
/** * ORA标准时间格式
*/ private static final SimpleDateFormat ORA_DATE_TIME_FORMAT = new SimpleDateFormat("yyyyMMddHHmm");
/** * 带时分秒的ORA标准时间格式
*/ private static final SimpleDateFormat
ORA_DATE_TIME_EXTENDED_FORMAT = new SimpleDateFormat("yyyyMMddHHmmss");
/** * 创建一个标准日期格式的克隆 * *
@return 标准日期格式的克隆 */ public static synchronized DateFormat getDateFormat()
{ /** * 详细设计: * 1.返回DATE_FORMAT */ return null; }
/** * 创建一个标准时间格式的克隆 * * @return 标准时间格式的克隆
*/ public static synchronized DateFormat getDateTimeFormat()
{ /** * 详细设计: * 1.返回DATE_TIME_FORMAT */ return null; } |
/** * 创建一个标准ORA日期格式的克隆 * *
@return 标准ORA日期格式的克隆 */ public static synchronized DateFormat getOraDateFormat()
{ /** * 详细设计: * 1.返回ORA_DATE_FORMAT */ return null; } /** * 创建一个标准ORA时间格式的克隆
* * @return 标准ORA时间格式的克隆 */ public static synchronized DateFormat getOraDateTimeFormat()
{ /** * 详细设计: * 1.返回ORA_DATE_TIME_FORMAT */ return null; } /**
* 将一个日期对象转换成为指定日期、时间格式的字符串。
* 如果日期对象为空,返回一个空字符串,而不是一个空对象。
* * @param theDate 要转换的日期对象 * @param theDateFormat 返回的日期字符串的格式
* @return 转换结果 */ public static synchronized String toString(Date theDate, DateFormat theDateFormat)
{ /** * 详细设计: * 1.theDate为空,则返回"" * 2.否则使用theDateFormat格式化 */ return null; }
/** * 将日期对象转换成为指定日期、时间格式的字符串形式。如果日期对象为空,返回
* 一个空字符串对象,而不是一个空对象。 * * @param theDate 将要转换为字符串的日期对象。
* @param hasTime 如果返回的字符串带时间则为true * @return 转换的结果
*/ public static synchronized String toString(Date theDate, boolean hasTime)
{ /** * 详细设计: * 1.如果有时间,则设置格式为getDateTimeFormat的返回值
* 2.否则设置格式为getDateFormat的返回值 * 3.调用toString(Date theDate, DateFormat theDateFormat) */ return null; } |
/** * 将日期对象转换成为指定ORA日期、时间格式的字符串形式。
如果日期对象为空,返回 * 一个空字符串对象,而不是一个空对象。
* * @param theDate 将要转换为字符串的日期对象。
* @param hasTime 如果返回的字符串带时间则为true * @return 转换的结果
*/ public static synchronized String toOraString(Date theDate, boolean hasTime)
{ /** * 详细设计: * 1.如果有时间,则设置格式为getOraDateTimeFormat()的返回值
* 2.否则设置格式为getOraDateFormat()的返回值
* 3.调用toString(Date theDate, DateFormat theDateFormat) */ return null; }
/** * 取得指定日期的所处月份的第一天 * * @param date 指定日期。
* @return 指定日期的所处月份的第一天 */ public static java.util.Date getFirstDayOfMonth(java.util.Date date)
{ /** * 详细设计: * 1.设置为1号 */ return null; } /** * 取得指定日期的所处月份的最后一天 * *
@param date 指定日期。 * @return 指定日期的所处月份的最后一天
*/ public static synchronized java.util.Date getLastDayOfMonth(java.util.Date date)
{ /** * 详细设计: * 1.如果date在1月,则为31日 * 2.如果date在2月,则为28日
* 3.如果date在3月,则为31日 * 4.如果date在4月,则为30日 * 5.如果date在5月,则为31日
* 6.如果date在6月,则为30日 * 7.如果date在7月,则为31日 * 8.如果date在8月,则为31日
* 9.如果date在9月,则为30日 * 10.如果date在10月,则为31日 * 11.如果date在11月,则为30日
* 12.如果date在12月,则为31日 * 1.如果date在闰年的2月,则为29日 */ return null; }
/** * 取得指定日期的所处星期的第一天 * * @param date 指定日期。
* @return 指定日期的所处星期的第一天
*/ public static synchronized java.util.Date getFirstDayOfWeek(java.util.Date date)
{ /** * 详细设计: * 1.如果date是星期日,则减0天 * 2.如果date是星期一,
则减1天 * 3.如果date是星期二,则减2天 * 4.如果date是星期三,
则减3天 * 5.如果date是星期四,则减4天 * 6.如果date是星期五,
则减5天 * 7.如果date是星期六,则减6天 */ return null; } |
/** * 取得指定日期的所处星期的最后一天 * * @param date 指定日期。
* @return 指定日期的所处星期的最后一天
*/ public static synchronized java.util.Date getLastDayOfWeek(java.util.Date date)
{ /** * 详细设计:
* 1.如果date是星期日,则加6天 * 2.如果date是星期一,则加5天
* 3.如果date是星期二,则加4天 * 4.如果date是星期三,则加3天
* 5.如果date是星期四,则加2天 * 6.如果date是星期五,则加1天
* 7.如果date是星期六,则加0天 */ return null; } /** * 取得指定日期的下一天 * * @param date 指定日期。
* @return 指定日期的下一天 */ public static synchronized java.util.Date getNextDay(java.util.Date date)
{ /** * 详细设计: * 1.指定日期加1天 */ return null; } /**
* 取得指定日期的下一个星期 * * @param date 指定日期。 * @return 指定日期的下一个星期
*/ public static synchronized java.util.Date getNextWeek(java.util.Date date){ /** * 详细设计:
* 1.指定日期加7天 */ return null; } /** * 取得指定日期的下一个月 * * @param date 指定日期。
* @return 指定日期的下一个月 */ public static synchronized java.util.Date getNextMonth(java.util.Date date)
{ /** * 详细设计: * 1.指定日期的月份加1 */ return null; } /**
* 取得指定日期的下一个星期的第一天 * * @param date 指定日期。
* @return 指定日期的下一个星期的第一天
*/ public static synchronized java.util.Date getFirstDayOfNextWeek(java.util.Date date)
{ /** * 详细设计: * 1.调用getNextWeek设置当前时间 * 2.以1为基础,调用getFirstDayOfWeek */ return null; }
/** * 取得指定日期的下一个月的第一天 * * @param date 指定日期。 * @return 指定日期的下一个月的第一天
*/ public static synchronized java.util.Date getFirstDayOfNextMonth(java.util.Date date){ /** * 详细设计:
* 1.调用getNextMonth设置当前时间 * 2.以1为基础,调用getFirstDayOfMonth */ return null; } /**
* 取得指定日期的下一个星期的最后一天 * * @param date 指定日期。
* @return 指定日期的下一个星期的最后一天
*/ public static synchronized java.util.Date getLastDayOfNextWeek(java.util.Date date)
{ /** * 详细设计: * 1.调用getNextWeek设置当前时间 * 2.以1为基础,调用getLastDayOfWeek */ return null; }
/** * 取得指定日期的下一个月的最后一天 * * @param date 指定日期。
* @return 指定日期的下一个月的最后一天
*/ public static synchronized java.util.Date getLastDayOfNextMonth(java.util.Date date){ /** * 详细设计:
* 1.调用getNextMonth设置当前时间 * 2.以1为基础,调用getLastDayOfMonth */ return null; }
/** * 判断指定日期的年份是否是闰年 * * @param date 指定日期。 * @return 是否闰年
*/ public static synchronized boolean isLeapYear(java.util.Date date){ /** * 详细设计:
* 1.被400整除是闰年,否则 * 2.不能被4整除则不是闰年
* 3.能被4整除同时不能被100整除则是闰年 * 3.能被4整除同时能被100整除则不是闰年
*/ return false; } /** * 得到指定日期的后一个工作日 * * @param date 指定日期。
* @return 指定日期的后一个工作日
*/ public static synchronized java.util.Date getNextWeekDay(java.util.Date date)
{ /** * 详细设计:
* 1.如果date是星期五,则加3天 * 2.如果date是星期六,则加2天 * 3.否则加1天 */ return null; }
/** * 得到指定日期的前一个工作日 * * @param date 指定日期。
* @return 指定日期的前一个工作日
*/ public static synchronized java.util.Date getPreviousWeekDay(java.util.Date date)
{ /** * 详细设计:
* 1.如果date是星期日,则减3天 * 2.如果date是星期六,则减2天 * 3.否则减1天 */ return null; } } |
|