Java JUnit 笔记

JUnit 是由 Erich Gamma 和 Kent Beck 编写的一个回归测试框架(regression testing framework),主要供 Java 开发人员编写单元测试。JUnit 是在极限编程和重构中被极力推荐使用的一个工具,因为它可以大大地提高开发的效率。JUnit 测试是程序员测试,即所谓白盒测试,因为程序员知道被测试的软件如何(How)完成功能和完成什么样(What)的功能。

JUnit 简介

JUnit 是什么
JUnit 是一个简单的开源框架,用于编写和运行可重复的测试。它是 xUnit 单元测试框架体系结构的一个实例。JUnit 功能包括:用于测试预期结果的断言、用于共享通用测试数据的测试夹具、用于运行测试的测试运行器。JUnit 最初由 Erich Gamma 和 Kent Beck 编写。

简单的说,JUnit 是 Java 中的一个单元测试开源库,基本上 JUnit 已经成为了 Java 单元测试的事实标准。目前 JUnit 存在 3 个主流版本,分别是 JUnit 3.x、JUnit 4.x、JUnit 5.x。其中 JUnit 3.x 已经过时,主流是 JUnit 4.x(4.x 版本开始支持 @Test 等注解,之前是要继承对于的测试基类的),而 JUnit 5.x 则是基于 Java 8 开发的测试框架,因此 JUnit 5.x 要求的最低 JDK 版本是 1.8,所以不是那么通用,我们主要学习 JUnit 4.x 就行了,目前 JUint 4.x 的最新 relase 版是 JUnit 4.12

什么是单元测试?
在计算机编程中,单元测试(英语:Unit Testing)又称为 模块测试,是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。对于面向过程编程,基本单元就是函数;对于面向对象编程,基本单元就是方法。好吧,其实本质来说都是函数而已,无论是 OPP 还是 OOP。而在 JUnit 中我们也是对方法进行测试。

通常来说,程序员每修改一次程序就会进行最少一次单元测试,在编写程序的过程中前后很可能要进行多次单元测试,以证实程序达到软件规格书要求的工作目标,没有程序错误;虽然单元测试不是什么必须的,但也不坏,这牵涉到项目管理的政策决定。

每个理想的测试案例独立于其它案例;为测试时隔离模块,经常使用 stubs、mock 或 fake 等测试马甲程序。单元测试通常由软件开发人员编写(俗称“白盒测试”),用于确保他们所写的代码匹配软件需求和遵循开发目标。它的实施方式可以是非常手动的(透过纸笔),或者是做成构建自动化的一部分。

白盒测试、灰盒测试、黑盒测试
所谓灰白黑,是指测试人员对软件的了解程度,白盒测试就是说测试人员非常了解软件的细节(软件开发人员),而黑盒测试就是说测试人员一点都不了解软件的细节,他只是测试软件提供的 API 是否有问题(普通用户),灰盒测试则处于白盒与黑盒之间,了解软件的大致逻辑,然后进行测试(测试人员)。

举个例子,某个软件出现了问题,那么:

  • 黑盒测试员:软件有问题,不能用了
  • 灰盒测试员:通过某些工具,发现原来是 X 的问题
  • 白盒测试员:通过检查程序源码,才知道是 Y 行代码出现了问题

这就是所谓的黑盒、白盒、灰盒测试,懂了吧。

JUnit 使用

首先,我们知道 JUnit 主要是用来进行白盒测试的,所以在 maven 的依赖配置中,它的 scope 一般是 test,因为我们只需要在测试环境中用到 JUnit,打包后的 jar 包里面是不需要这个依赖的。所以:

然后项目的目录结构如下:

按照约定,我们将项目源码放到 src/main/java 目录,将测试源码放到 src/test/java 目录,相应的,项目资源放到 src/main/resources 目录,测试资源放到 src/test/resources 目录,这些文件会自动加入到运行时的 CLASSPATH 中,便于程序使用。注意,maven 会自动将 src/main/javasrc/test/java 进行合并,所以处于同一个包中的源码类和测试类是会放到一起的,也就是说我们可以直接在 src/test/java 的测试类里面使用同名包下面的其它类(比如上面的 com.zfl9.CalculatorTest 测试类中就可以直接使用 com.zfl9.Calculator 工具类,因为它们实际会被放到同一个目录空间中)。

我们来看看 com.zfl9.Calculator 工具类的源码:

简单的加减乘除而已,add/sub/mul/div。来看看对应的测试类(测试类命名一般用 Test 结尾):

最基本的测试大概就是上面这样,首先导入 org.junit.Test 注解,然后导入 org.junit.Assert 工具类的所有静态断言方法(不使用静态导入也行,但是使用静态导入明显是更方便的)。注意到测试类与源码类的命名特点了吗,测试类一般就是在源码类的名字后面加上 Test 后缀,这样的好处是可读性好,别人一看就知道这是用于测试 Calculator 的测试类,当然 junit 并没有规定测试类与源码类的命名规则,但是遵循这个约定是有好处的。

然后就是 @Test 注解了,我们需要将这个注解放到对应的测试方法上面,告诉 JUnit,这是一个用于测试的方法,而对于的测试方法的命名也是有约定的(注意是约定不是规定,所以名字什么的是可以自取的,当然遵守约定总是有好处的),约定是使用 test 开头,使用驼峰命名法来进行命名,如上。每个测试方法都是 public void MethodName() 修饰的,访问性为 public,没有返回值,不接收参数,是一个实例方法。

一般来说,每个测试方法都对应一个源码方法,比如 add 就是 testAdddiv 就是 testDiv。然后就是利用 Assert 提供的断言方法进行测试了,比如最常见的就是 assertEquals(expecteds, actuals),expected 表示期望结果值,而 actual 表示实际结果值,而 assertEquals 表示,如果实际值不等于预期值,则抛出断言错误,在进行 mvn test 生命周期时就会提示对应的错误信息。如果相等则没有什么输出。

我们来看看默认的执行输出:

我们来改一下测试类,故意让它与预期值不相等,看下什么结果:

我们将 2 改为了 3,故意让它报错,这是运行结果:

资源对象的创建与销毁
在进行单元测试时,我们通常需要在执行测试方法前创建要用到的对象,然后在测试方法执行完之后又要销毁它们(释放资源),最无脑的做法是在每个测试方法的前后拷贝这些创建和销毁的代码(很多重复代码,不好),稍微明智一点的做法就是声明一个私有数据成员,然后将创建对象的代码单独放到一个成员函数中,销毁对象的代码也是单独放到一个成员函数中,然后在每个测试方法的首尾加入这两个函数的调用就行。

但是 junit 提供了一个更简便的方法,我们不需要在测试方法的首尾加入两个函数的调用,而是直接使用 @Before@After 注解来标注创建函数和销毁函数,junit 在进行测试时会自动在运行测试方法前调用 @Before 方法,测试函数返回后又会自动调用 @After 方法,很方便,例子:

运行结果:

Test 注解的两个参数
@Test 注解是 junit 4.x 中最常用的一个注解元素,它有两个可选参数,分别是:

  • long timeout:表示该方法必须在指定的时间内执行完成(单位为毫秒),如果不是则表示测试失败
  • Class<? extends Throwable> expected:表示该方法必须抛出指定异常类或其子类,否则测试失败

例子:

运行:

如果我们把抛出异常的那条语句给注释掉,那么会测试失败:

运行:

同理,如果我们不传递对应的异常类给 Test 注解,而测试方法可能会抛出异常,则也会导致测试失败:

运行结果:

然后我们来试试 timeout 参数,表示该方法必须能够在指定时间内返回:

我们设置的是该方法必须在 10ms 之内返回,然而我们故意在里面 sleep 100ms,结果:

简单的测试类模板

一次性的 before 和 after 方法
我们注意到,上面的 @Before@After 注解的方法是每次执行测试方法时都会被执行的,而有时候我们可能需要 setUp() 方法和 tearDown() 方法执行一次就行(首尾),在 junit 中是运行我们这样做的,我们只需使用 @BeforeClass@AfterClass 注解来标注对应的 public static void 方法就行:

执行顺序如下:

JUnit 进阶

Javadoc APIhttps://junit.org/junit4/javadoc/latest/index.html

在第二章节,我们介绍了 junit 最常见的用法,涉及到的东西有:@Test 注解、@Before/@After 注解、@BeforeClass/@AfterClass 注解、以及 @Test 注解的 expected、timeout 参数。

我们来简单总结编写测试代码时要注意的几个东西:

  • 测试方法必须使用 @Test 注解进行标注
  • 测试方法必须使用 public void 修饰,不能带任何的参数
  • 源代码和测试代码的存放要分开,可参考 maven 标准目录结构
  • 测试类所在的包名应该和被测试类所在的包名保持一致(很重要)
  • 测试单元的每个方法必须可以独立测试,测试方法间不能有任何依赖
  • 测试类使用 Test 作为类名的后缀(不是必须,但强烈建议)
  • 测试方法使用 test 作为方法名的前缀(不是必须,但强烈建议)
  • 准备测试数据的方法应命名为 setUp(),并且使用 @Before 进行标注
  • 销毁测试数据的方法应命名为 tearDown(),并且使用 @After 进行标注
  • 一次性的 setUp、tearDown,应使用 @BeforeClass@AfterClass 注解
  • @Test 注解所标注的测试方法是可以抛出检查异常和非检查异常的,没有要求
  • 如果某些测试方法因为各种原因暂时不想进行测试,可以使用 @Ignore 进行注解
  • 如果某个测试类中不想被 Runner 运行,你也可以使用 @Ignore 来注解整个测试类

我们唯一没有介绍的就是 @Ignore 注解,我们来试一下,注解测试方法:

运行结果如下:

注意到没,Skipped 的值为 1,表示 Runner 在执行测试时跳过了一个测试方法。
当然我们也可以直接使用 @Ignore 来注解整个类,这样整个测试类就不会运行了:

运行结果如下:

Assert 工具类的常见方法
JUnit 提供了一些辅助函数,他们用来帮助我们确定被测试的方法是否按照预期正常执行,这些辅助函数我们称之为 断言(Assertion)。JUnit4 所有的断言都在 org.junit.Assert 类中,Assert 类包含了一组 静态 的测试方法,用于验证 期望值 expected实际值 actual 之间的逻辑关系是否正确,如果不符合我们的预期则表示测试未通过。Assert 实用类提供的方法都是静态方法,即 public static void,常见的有:

你可能会对 assertEquals()assertSame() 方法产生疑惑,其实很好分辨,一个是通过调用 equals() 方法进行判断是否“相等”,一个是通过调用 == 运算符进行判断是否“相等”,后者判断的其实是对象的内存地址是否相同,这和前者的判断是不一样的,这样说估计你就能记住它们的区别了。

除了上面这些方法之外,还有两个 fail() 方法,用来手动导致测试失败:

什么是”手动导致测试失败”?很简单,比如:

运行结果:

思考:测试类是如何运行的?
大家刚开始使用 JUnit 的时候,可能会跟我一样有一个疑问,JUnit 没有 main() 方法,那它是怎么开始执行的呢?众所周知,不管是什么程序,都必须有一个程序执行入口,而这个入口通常是 main() 方法。显然,JUnit 能直接执行某个测试方法,那么它肯定会有一个程序执行入口。没错,其实在 org.junit.runner 包下,有个 JUnitCore.java 类,这个类有一个标准的 main() 方法,这个其实就是 JUnit 程序的执行入口,其代码如下:

通过分析里面的runMain()方法,可以找到最终的执行代码如下:

可以看到,所有的单元测试方法都是通过 Runner 来执行的。Runner 只是一个抽象类,它是用来跑测试用例并通知结果的,JUnit 提供了很多 Runner 的实现类,可以根据不同的情况选择不同的 test runner。

当然,我们一般都是使用 maven 或 IDE 来执行测试,你也许注意到了,我们在之前的使用中,并没有手动运行什么 Runner,而是直接使用 mvn test 来跑单元测试,其实 maven 自动运行了对应的 Runner 而已啦。

JUnit 扩展

五个常用注解

  • @BeforeClass:初始化方法,public static void init(),只允许一次
  • @Before:准备测试数据,public void setUp(),在每个 Test 方法前运行
  • @Test:要测试的方法,public void testXxx(),由 JUnit Runner 来运行
  • @After:回收测试数据,public void tearDown(),在每个 Test 方法后运行
  • @AfterClass:销毁方法,public static void destroy(),也是只会运行一次

JUnit 4.4 引入的 Hamcrest
Hamcrest 是一个协助编写用 Java 语言进行软件测试的框架。它支持创建自定义的断言匹配器(assertion matchers),名称 Hamcrest 即为 matchers 的异位构词,允许声明式定义匹配规则。这些匹配器在单元测试框架(例如 JUnit 和 jMock)中有用。Hamcrest 已经被移植到 Java、C++、Objective-C、Python、ActionScript 3、PHP、JavaScript 和 Erlang。

“第一代”断言表达式
原始的断言语句,基本上和 java 语言规范提供的断言是一样的,不好用:

“第二代”断言表达式
第一代断言表达式太原始了,断言失败产生的错误信息不友好,于是第二代断言表达式提供了一组断言语句,从而产生更友好的错误信息:

“第三代”断言表达式
因为断言条件有很多,导致测试框架需要提供数量庞大的断言语句,不利于维护,所以第三代断言表达式提供了更灵活的断言语句 assert_that

这样做的好处是,断言失败时依旧可以得到可读性良好的错误信息,同时也有了更强大的可扩展性(可以编写自定义的断言操作)、可读性(与口语相似)。我们知道,junit 提供的是“第二代断言表达式”,即 assertXxx(),不过,从 junit 4.4 起,junit 引入了 assertThat(actual, matcher) 语法,这就是所谓的“第三代断言表达式”,而 Hamcrest 则提供了很多常用的 matcher 匹配器,如字符串匹配器、集合/数组匹配器、逻辑运算符等,junit 提供 2 个重载形式的 assertThat() 断言方法,如下:

如何使用 Hamcrest
junit 4.12 内部默认引用了 hamcrest-core 1.3,所以我们只需要再引入 hamcrest-library 1.3 就可以了:

声明一句,junit 提供的 assertXxx() 和 hamcrest 提供的 assertThat() 并没有冲突,实际上两个都会去使用,哪个方便用哪个。

然后,我们只需要在测试类中静态导入 junit 的断言方法和 hamcrest 的匹配器方法,就可以开始使用了:

hamcrest 提供的所有断言测试方法(匹配器方法):http://hamcrest.org/JavaHamcrest/javadoc/1.3/org/hamcrest/Matchers.html

assertThat() 使用例子:

常用的一些断言方法

  • is - decorator for equalTo to improve readability
  • not - matches if the wrapped matcher doesn’t match and vice
  • allOf - matches if all matchers match (short circuits)
  • anyOf - matches if any matchers match (short circuits)
  • notNullValue, nullValue - test for null
  • equalTo - test object equality using the equals method
  • instanceOf, isCompatibleType - test type
  • sameInstance - test object identity
  • hasEntry, hasKey, hasValue - test a map contains an entry, key or value
  • hasItem, hasItems - test a collection contains elements
  • hasItemInArray - test an array contains an element
  • closeTo - test floating point values are close to a given value
  • greaterThan, greaterThanOrEqualTo, lessThan, lessThanOrEqualTo
  • hasToString - test Object.toString
  • equalToIgnoringCase - test string equality ignoring case
  • equalToIgnoringWhiteSpace - test string equality ignoring differences in runs of whitespace
  • containsString, endsWith, startsWith - test string matching

自定义匹配器(扩展)

使用方法: