JUnit-单元测试
所谓单元测试,就是针对最小功能单元的测试。
在Java程序中,最小功能单元是方法,因此顾名思义,一个单元测试就是针对单个Java方法的测试。
我们设想的理想开发状态是一种测试驱动开发。
就是说,我们先编写接口,紧接着编写测试,编写完测试之后,我们才开始真正编写实现代码。
在编写实现代码的过程中,一边写,一边测,什么时候测试全部通过了,那就表示编写的实现完成了。
当然啦,这是理想状况,我们常常是先写好了实现代码,然后希望对已有的代码做单元测试。
在Java中,我们有专门的单元测试框架-JUnit.
JUnit
JUnit是一个开源的Java语言的单元测试框架,专门针对Java设计,使用最广泛。JUnit是事实上的单元测试的标准框架,任何Java开发者都应当学习并使用JUnit编写单元测试。目前最新版是JUnit5。
使用JUnit编写单元测试的好处在于,我们可以非常简单地组织测试代码,并随时运行它们,JUnit就会给出成功的测试和失败的测试,还可以生成测试报告,不仅包含测试的成功率,还可以统计测试的代码覆盖率,即被测试的代码本身有多少经过了测试。对于高质量的代码来说,测试覆盖率应该在80%以上。
举个例子:
我们在src目录下,有一个Factorial.java,
1 | package com.sosactwt.junit; |
现在我们在test目录下,建一个FactorialTest.java,
1 | package com.sosactwt.junit; |
我们最常用的方法便是essertEquals(expected,actual),expected和actual有很多种类,如这里的long,也可以是int,string,object等等。
如果expected,actual相等,则测试会通过,如果不等,那么essertEquals便会抛出异常。
当然,你可以自定义一场抛出信息,如assertEquals(6, Factorial.fact(3),"They are not equal,bro");
比较特殊的一点是,当我们比较的是浮点类型,由于浮点类型无法精确地比较,因此,
我们需要调用assertEquals(double expected, double actual, double delta)
这个重载方法,用delta去指定一个误差值,一般我们认为浮点数比较时,差值<=10e-6即可认为相等,故写法如下:
assertEquals(0.1, 2/20,0.0000001)
;
类似的还有:
assertTrue()
: 期待结果为true
assertFalse()
: 期待结果为false
assertNotNull()
: 期待结果为非null
assertArrayEquals()
: 期待结果为数组并与期望数组每个元素的值均相等- …
编写单元测试需要遵循的小规范:
- 单元测试代码本身必须非常简单,能一下看明白,绝不能再为测试代码编写测试;
- 每个单元测试应当相互独立,测试结果不该依赖于运行的顺序;
- 测试的时候不但要覆盖常用测试用例,还要特别注意测试边界条件,例如输入为
0
,null
,空字符串""
等等情况。
编写单元测试的好处:
单元测试可以确保单个方法按照正确预期运行,如果修改了某个方法的代码,只需确保其对应的单元测试通过,即可认为改动正确。此外,测试代码本身就可以作为示例代码,用来演示如何调用该方法。
使用JUnit进行单元测试,我们可以使用断言(Assertion
)来测试期望结果,可以方便地组织和运行测试,并方便地查看测试结果。此外,JUnit既可以直接在IDE中运行,也可以方便地集成到Maven这些自动化工具中运行。
使用Fixture
所谓Fixture,就是由JUnit提供的编写测试前准备,编写测试后清理的固定代码。
自测试的时候,我们经常遇到一个对象需要初始化,测试完需要清理的情况,如果每一个@Test方法都写一遍这样的重复代码,很麻烦,代码也显得很臃肿。Fixture就是用来解决者个问题的。
我们常用的有注解有@BeforeEach
,@AfterEach
,@BeforeAll
,@AfterEach
1 | public class Calculator { |
这个类的功能很简单,但是测试的时候,我们要先初始化对象,我们不必在每个测试方法中都写上初始化代码,而是通过@BeforeEach
来初始化,通过@AfterEach
来清理资源:
1 | public class CalculatorTest { |
在CalculatorTest
测试中,有两个标记为@BeforeEach
和@AfterEach
的方法,它们会在运行每个@Test
方法前后自动运行。
还有一些资源初始化和清理可能更加繁琐,而且会耗费较长的时间,例如初始化数据库。JUnit还提供了@BeforeAll
和@AfterAll
,它们在运行所有@Test前后运行,顺序如下:
1 | invokeBeforeAll(CalculatorTest.class); |
因为@BeforeAll
和@AfterAll
在所有@Test
方法运行前后仅运行一次,因此,它们只能初始化静态变量,例如:
1 | public class DatabaseTest { |
事实上,@BeforeAll
和@AfterAll
也只能标注在静态方法上。
总结出编写Fixture的套路如下:
- 对于实例变量,在
@BeforeEach
中初始化,在@AfterEach
中清理,它们在各个@Test
方法中互不影响,因为是不同的实例; - 对于静态变量,在
@BeforeAll
中初始化,在@AfterAll
中清理,它们在各个@Test
方法中均是唯一实例,会影响各个@Test
方法。
大多数情况下,使用@BeforeEach
和@AfterEach
就足够了。只有某些测试资源初始化耗费时间太长,以至于我们不得不尽量“复用”时才会用到@BeforeAll
和@AfterAll
。
最后,注意到每次运行一个@Test
方法前,JUnit首先创建一个XxxTest
实例,因此,每个@Test
方法内部的成员变量都是独立的,不能也无法把成员变量的状态从一个@Test
方法带到另一个@Test
方法。
编写Fixture是指针对每个@Test
方法,编写@BeforeEach
方法用于初始化测试资源,编写@AfterEach
用于清理测试资源;
必要时,可以编写@BeforeAll
和@AfterAll
,使用静态变量来初始化耗时的资源,并且在所有@Test
方法的运行前后仅执行一次。
异常测试
在写程序时,我们常常要处理异常,我们常常会在编写的方法中抛出一些异常。
那么很自然的想到,我们也该对抛出的异常做做测试,看看在程序有问题时异常能否正常抛出。
对可能抛出的异常进行测试,这本身就是测试的重要环节。
如,我们的代码如下:
1 | public class Factorial { |
现在我们想知道,当我们指定n为负数时,是否能正确抛出异常。
在JUnit测试中,我们可以编写一个@Test
方法专门测试异常:
1 |
|
JUnit提供assertThrows()
来期望捕获一个指定的异常。第二个参数Executable
封装了我们要执行的会产生异常的代码。当我们执行Factorial.fact(-1)
时,必定抛出IllegalArgumentException
。
assertThrows()
在捕获到指定异常时表示通过测试,未捕获到异常,或者捕获到的异常类型不对,均表示测试失败。
如果觉得编写一个Executable
的匿名类太繁琐了,实际上,Java 8开始引入了函数式编程,所有单方法接口都可以简写如下:
1 |
|
小结
测试异常可以使用assertThrows()
,期待捕获到指定类型的异常;
对可能发生的每种类型的异常都必须进行测试。
条件测试
在运行测试的时候,有些时候,我们需要排出某些@Test
方法,不要让它运行,这时,我们就可以给它标记一个@Disabled
:
1 |
|
为什么我们不直接注释掉@Test
,而是要加一个@Disabled
?这是因为注释掉@Test
,JUnit就不知道这是个测试方法,而加上@Disabled
,JUnit仍然识别出这是个测试方法,只是暂时不运行。它会在测试结果中显示:
1 | Tests run: 68, Failures: 2, Errors: 0, Skipped: 5 |
类似@Disabled
这种注解就称为条件测试,JUnit根据不同的条件注解,决定是否运行当前的@Test
方法。
我们来看一个例子:
1 | public class Config { |
我们想要测试getConfigFile()
这个方法,但是在Windows上跑,和在Linux上跑的代码路径不同,因此,针对两个系统的测试方法,其中一个只能在Windows上跑,另一个只能在Mac/Linux上跑:
1 |
|
因此,我们给上述两个测试方法分别加上条件如下:
1 |
|
@EnableOnOs
就是一个条件测试判断。
我们来看一些常用的条件测试:
不在Windows平台执行的测试,可以加上@DisabledOnOs(OS.WINDOWS)
:
1 |
|
只能在Java 9或更高版本执行的测试,可以加上@DisabledOnJre(JRE.JAVA_8)
:
1 |
|
只能在64位操作系统上执行的测试,可以用@EnabledIfSystemProperty
判断:
1 |
|
需要传入环境变量DEBUG=true
才能执行的测试,可以用@EnabledIfEnvironmentVariable
:
1 |
|
参数化测试
与普通测试的区别在于,在参数化测试中,一个测试方法至少要接收一个参数,然后我们传入一组参数反复运行。即把测试数据组织起来,用不同的测试数据调用相同的测试方法
JUnit提供了一个@ParameterizedTest
注解,用来进行参数化测试。
假设我们想对Math.abs()
进行测试,先用一组正数进行测试:
1 |
|
再用一组负数进行测试:
1 |
|
注意到参数化测试的注解是@ParameterizedTest
,而不是普通的@Test
。
现在问题来了:参数如何传入?
最简单的方法是通过@MethodSource
注解,它允许我们编写一个同名的静态方法来提供测试参数:
1 |
|
上面的代码很容易理解:静态方法testCapitalize()
返回了一组测试参数,每个参数都包含两个String
,正好作为测试方法的两个参数传入。
如果静态方法和测试方法的名称不同,@MethodSource也允许指定方法名。但使用默认同名方法最方便。
另一种传入测试参数的方法是使用@CsvSource
,它的每一个字符串表示一行,一行包含的若干参数用,
分隔,因此,上述测试又可以改写如下:
1 |
|
如果有成百上千的测试输入,那么,直接写@CsvSource
就很不方便。这个时候,我们可以把测试数据提到一个独立的CSV文件中,然后标注上@CsvFileSource
:
1 |
|
JUnit只在classpath中查找指定的CSV文件,因此,test-capitalize.csv
这个文件要放到test
目录下,内容如下:
1 | apple, Apple |
参考: