b1cat`s

Java单元测试

Word count: 2.4kReading time: 9 min
2020/02/17 1

Java单元测试

什么是单元测试?单元测试就是针对最小的功能单元编写测试代码。对于Java来说,最小的功能单元是方法,因此对Java单元测试就是对Java单个方法测试。

测试驱动开发:

是指先编写接口,接着写测试,再开始编写实现代码。在编写过程中,边写边测,测试通过之时就是代码大成之日。传说中的TDD。

当然测试驱动开发是一种理想状态。大部分情况是先写完了实现代码,需要测试…..

这时候就需要一种测试框架,辅助编写测试。

JUnit

JUnit是一个开源的Java语言的单元测试框架,专门针对Java设计,使用最广泛。JUnit是事实上的单元测试的标准框架,任何Java开发者都应当学习并使用JUnit编写单元测试。

好处:我们可以非常简单地组织测试代码,并随时运行它们,JUnit就会给出成功的测试和失败的测试,还可以生成测试报告,不仅包含测试的成功率,还可以统计测试的代码覆盖率,即被测试的代码本身有多少经过了测试。对于高质量的代码来说,测试覆盖率应该在80%以上。

几乎所有的IDE工具都集成了JUnit,这样我们就可以直接在IDE中编写并运行JUnit测试。

以Eclipse为例,当我们已经编写了一个Factorial.java文件后,我们想对其进行测试,需要编写一个对应的FactorialTest.java文件,以Test为后缀是一个惯例,并分别将其放入srctest目录中。最后,在Project - Properties - Java Build Path - Libraries中添加JUnit 5的库。

image-20200217010013955 image-20200217010039661

举个栗子

  • 这个是需要被测试的code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//Factorial.java
package org.b1cat.preJava;

public class Factorial {
//计算阶乘的类

public static long fact(long n) {
long r = 1;
for (long i = 1; i <= n; i++) {
r = r * i;
}
return r;
}
}
  • 再写个测试code,文件名一般以Test结尾
    • assertEquals(expected, actual)是最常用的测试方法,它在Assertion类中定义。Assertion还定义了其他断言方法,例如:
    • assertTrue(): 期待结果为true
    • assertFalse(): 期待结果为false
    • assertNotNull(): 期待结果为非null
    • assertArrayEquals(): 期待结果为数组并与期望数组每个元素的值均相等
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//FactorialTest.java
package org.b1cat.preJava;

import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;

public class FactorialTest {

@Test //测试专用注解
void testFact() {
assertEquals(1, Factorial.fact(1)); //assertEquals期望执行计算阶乘1返回结果1
assertEquals(2, Factorial.fact(2));
assertEquals(26, Factorial.fact(3)); //assertEquals期望执行计算阶乘3返回结果26
assertEquals(3628800, Factorial.fact(10));
assertEquals(2432902008176640000L, Factorial.fact(20));

}
}
  • 运行:
image-20200217010914802
  • 结果
image-20200217010956856

返回结果是没有运行通过测试,因为我们在测试代码中assertEquals期望执行计算阶乘3返回结果26显然不对,那么就需要我们改一下,assertEquals(6, Factorial.fact(3)); ,然后再运行测试代码

image-20200217011223594

绿色的,非常OK。

注:使用浮点数时,由于浮点数无法精确地进行比较,因此,我们需要调用assertEquals(double expected, double actual, double delta)这个重载方法,指定一个误差值:

1
assertEquals(0.1, Math.abs(1 - 9 / 10.0), 0.0000001);

在编写单元测试的时候,我们要遵循一定的规范:

  • 一是单元测试代码本身必须非常简单,能一下看明白,决不能再为测试代码编写测试;

  • 二是每个单元测试应当互相独立,不依赖运行的顺序;

  • 三是测试时不但要覆盖常用测试用例,还要特别注意测试边界条件,例如输入为0null,空字符串""等情况

使用Fixture

为了方便管理(创建、清除)每次@Test方法调用的对象,JUnit提供了编写测试前准备,测试后清理的固定代码,我们称之为Fixture。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class CalculatorTest{
Calculator calculator;

@BeforeEach
public void setUp(){
this.calculator = new Calculator();
}

@AfterEach
public void tearDown(){
this.calculator = null;
}

@Test
void testAdd(){}

@Test
void testSub(){}

@Test
....
}
1
2
3
4
5
6
7
8
9
//上面的测试代码在JUnit中运行顺序如下:
//@BeforeEach和@AfterEach会“环绕”在每个@Test方法前后。

for (Method testMethod : findTestMethods(CalculatorTest.class)) {
var test = new CalculatorTest(); // 创建Test实例
invokeBeforeEach(test);
invokeTestMethod(test, testMethod);
invokeAfterEach(test);
}

还有一些资源初始化和清理可能更加繁琐,而且会耗费较长的时间,例如初始化数据库。JUnit还提供了@BeforeAll@AfterAll,它们在运行所有@Test前后运行。因此,它们只能初始化静态变量。

因此,我们总结出编写Fixture的套路如下:

  1. 对于实例变量,在@BeforeEach中初始化,在@AfterEach中清理,它们在各个@Test方法中互不影响,因为是不同的实例;
  2. 对于静态变量,在@BeforeAll中初始化,在@AfterAll中清理,它们在各个@Test方法中均是唯一实例,会影响各个@Test方法。

异常测试

有时候我们需要对业务代码中,预期出现的异常做测试。举个栗子

1
2
3
4
5
6
7
8
9
10
11
12
public class Factorial {
public static long fact(long n) {
if (n < 0) { //判断参数n是否为负数,是就抛出IllegalArgumentException
throw new IllegalArgumentException();
}
long r = 1;
for (long i = 1; i <= n; i++) {
r = r * i;
}
return r;
}
}

现在对异常进行测试

1
2
3
4
5
6
7
8
9
@Test
void testNegative() {
assertThrows(IllegalArgumentException.class, new Executable(){
@Overrid
public void execute() throws Throwable {
Factorial.fact(-1);
}
});
}

JUnit提供assertThrows()来期望捕获一个指定的异常。第二个参数Executable封装了我们要执行的会产生异常的代码。当我们执行Factorial.fact(-1)时,必定抛出IllegalArgumentExceptionassertThrows()在捕获到指定异常时表示通过测试,未捕获到异常,或者捕获到的异常类型不对,均表示测试失败。

有些童鞋会觉得编写一个Executable的匿名类太繁琐了。实际上,Java 8开始引入了函数式编程,所有单方法接口都可以简写如下:

1
2
3
4
5
6
@Test
void testNegative() {
assertThrows(IllegalArgumentException.class, () -> { //->语法就是函数式接口的实现
Factorial.fact(-1);
});
}

条件测试

在运行测试期间,有时候,需要根据执行环境不同,选择性执行测试用例。比如Win和Unix系统的一些路径不同,所以要根据不同的系统执行不同的测试。

1
2
3
4
5
6
7
8
9
10
11
@Test
@EnableOnOs(OS.WINDWOS)
void testWindows() {
assertEquals("C:\\test.ini", config.getConfigFile("test.ini"));
}

@Test
@EnabledOnOs({ OS.LINUX, OS.MAC })
void testLinuxAndMac() {
assertEquals("/usr/local/test.cfg", config.getConfigFile("test.cfg"));
}

@EnabledOnOs就是一个条件判断

我们来看一些常用的条件测试:

  • 不在Windows平台执行的测试,可以加上@DisabledOnOs(OS.WINDOWS)
  • 需要排出某些@Test方法,不要让它运行,这时,我们就可以给它标记一个@Disabled
  • 只能在Java 9或更高版本执行的测试,可以加上@DisabledOnJre(JRE.JAVA_8)
  • 只能在64位操作系统上执行的测试,可以用@EnabledIfSystemProperty判断
  • 需要传入环境变量DEBUG=true才能执行的测试,可以用@EnabledIfEnvironmentVariable
  • 最后,万能的@EnableIf可以执行任意Java语句并根据返回的boolean决定是否执行测试
1
2
3
4
5
6
//演示了一个只能在星期日执行的测试
@Test
@EnabledIf("java.time.LocalDate.now().getDayOfWeek()==java.time.DayOfWeek.SUNDAY")
void testOnlyOnSunday() {
// TODO: this test is only run on Sunday
}

没有执行的测试,在运行测试结果是被标记为Skipped

参数化测试

如果待测试的输入和输出是一组数据: 可以把测试数据组织起来 用不同的测试数据调用相同的测试方法

参数化测试和普通测试稍微不同的地方在于,一个测试方法需要接收至少一个参数,然后,传入一组参数反复运行。

JUnit提供了一个@ParameterizedTest注解,用来进行参数化测试。

假设我们想对Math.abs()进行测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
//先用一组正数进行测试
@ParameterizedTest
@ValueSource(ints = { 0, 1, 5, 100 })
void testAbs(int x) {
assertEquals(x, Math.abs(x));
}

//再用一组负数进行测试
@ParameterizedTest
@ValueSource(ints = { -1, -5, -100 })
void testAbsNegative(int x) {
assertEquals(-x, Math.abs(x));
}

注意到参数化测试的注解是@ParameterizedTest,而不是普通的@Test

输入,预期输出都为参数

要用参数化测试的方法来测试,我们不但要给出输入,还要给出预期输出。因此,测试方法至少需要接收两个参数:

1
2
3
4
@ParameterizedTest
void testCapitalize(String input, String result) {
assertEquals(result, StringUtils.capitalize(input));
}

现在问题来了:参数如何传入?

    1. 最简单的方法是通过@MethodSource注解,它允许我们编写一个同名的静态方法来提供测试参数:
1
2
3
4
5
6
7
8
9
10
11
12
@ParameterizedTest
@MethodSource
void testCapitalize(String input, String result) {
assertEquals(result, StringUtils.capitalize(input));
}

static List<Arguments> testCapitalize() {
return List.of( // arguments:
Arguments.arguments("abc", "Abc"), //
Arguments.arguments("APPLE", "Apple"), //
Arguments.arguments("gooD", "Good"));
}
    1. 使用@CsvSource,它的每一个字符串表示一行,一行包含的若干参数用,分隔
1
2
3
4
5
@ParameterizedTest
@CsvSource({ "abc, Abc", "APPLE, Apple", "gooD, Good" })
void testCapitalize(String input, String result) {
assertEquals(result, StringUtils.capitalize(input));
}
    1. 如果有成百上千的测试输入,可以把测试数据提到一个独立的CSV文件中,然后标注上@CsvFileSource
1
2
3
4
5
@ParameterizedTest
@CsvFileSource(resources = { "/test-capitalize.csv" })
void testCapitalizeUsingCsvFile(String input, String result) {
assertEquals(result, StringUtils.capitalize(input));
}

JUnit只在classpath中查找指定的CSV文件,因此,test-capitalize.csv这个文件要放到test目录下。

CATALOG
  1. 1. Java单元测试
    1. 1.1. JUnit
  2. 1.2. 使用Fixture
  3. 1.3. 异常测试
  4. 1.4. 条件测试
  5. 1.5. 参数化测试