JUnit 单元测试

编写 JUnit 测试

一个简单的例子:

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

class FactorialTest {
    class Factorial {
        // fact 方法计算一个数的阶乘
        public static long fact(long n) {
            long r = 1;
            for (long i = 1; i <= n; i++) {
                r = r * i;
            }
            return r;
        }
    }

    @Test
    void testFact() {
        assertEquals(1, Factorial.fact(1));
        assertEquals(2, Factorial.fact(2));
        assertEquals(6, Factorial.fact(3));
        assertEquals(3628800, Factorial.fact(10));
        assertEquals(2432902008176640000L, Factorial.fact(20));
    }
}

assertEquals(expected, actual) 是最常用的测试方法,它在 Assertion 类中定义。Assertion还定义了其他断言方法,例如:

  • assertTrue(): 期待结果为true
  • assertFalse(): 期待结果为false
  • assertNotNull(): 期待结果为非null
  • assertArrayEquals(): 期待结果为数组并与期望数组每个元素的值均相等
  • ...

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

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

使用 Fixture

我们来看一个具体的 Calculator 的例子:

public class Calculator {
    private long n = 0;

    public long add(long x) {
        n = n + x;
        return n;
    }

    public long sub(long x) {
        n = n - x;
        return n;
    }
}

这个类的功能很简单,但是在测试的时候,我们要先初始化对象。
我们不必在每个测试方法中都写上初始化代码,而是通过 @BeforeEach 来初始化,通过 @AfterEach 来清理资源:

public class CalculatorTest {
    Calculator calculator;

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

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

    @Test
    void testAdd() {
        assertEquals(100, this.calculator.add(100));
        assertEquals(150, this.calculator.add(50));
        assertEquals(130, this.calculator.add(-20));
    }

    @Test
    void testSub() {
        assertEquals(-100, this.calculator.sub(100));
        assertEquals(-150, this.calculator.sub(50));
        assertEquals(-130, this.calculator.sub(-20));
    }
}

在 CalculatorTest 测试中,有两个标记为 @BeforeEach 和 @AfterEach 的方法,它们会在运行每个 @Test 方法的前后自动运行。

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

public class DatabaseTest {
    static Database db;

    @BeforeAll
    public static void initDatabase() {
        db = createDb(...);
    }

    @AfterAll
    public static void dropDatabase() {
        ...
    }
}

事实上,@BeforeAll 和 @AfterAll 也只能标注在静态方法上。

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

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

大多数情况下,使用 @BeforeEach 和 @AfterEach 就足够了,只有某些测试资源的初始化耗费时间太长,以至于我们不得不尽量“复用”时才会用到 @BeforeAll 和 @AfterAll 。

最后,注意到每次运行一个 @Test 方法前,JUnit 首先创建一个 XxxTest 类的实例,因此,每个 @Test 方法内部的成员变量都是独立的,不能也无法把成员变量的状态从一个 @Test 方法带到另一个 @Test 方法。

异常测试

在 Java 程序中,异常处理是非常重要的。我们自己编写的方法,也经常抛出各种异常。对于可能抛出的异常进行测试,本身就是测试的重要环节。因此,在编写 JUnit 测试的时候,除了正常的输入输出,我们还要特别针对可能导致异常的情况进行测试。

我们仍然用 Factorial 举例,在方法入口,我们增加了对参数 n 的检查,如果为负数,则直接抛出 IllegalArgumentException 。现在,我们希望对异常进行测试。在 JUnit 测试中,我们可以编写一个 @Test 方法专门测试异常。
JUnit 提供 assertThrows() 来期望捕获一个指定的异常。第二个参数 Executable 封装了我们要执行的会产生异常的代码。当我们执行 Factorial.fact(-1) 时,必定抛出IllegalArgumentException 。assertThrows() 在捕获到指定异常时表示通过测试,未捕获到异常或者捕获到的异常类型不对,均表示测试失败:

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.function.Executable;

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

class FactorialTest {
    class Factorial {
        public static long fact(long n) {
            if (n < 0) {
                throw new IllegalArgumentException();
            }
            long r = 1;
            for (long i = 1; i <= n; i++) {
                r = r * i;
            }
            return r;
        }
    }

    @Test
    void testNegative() {
        assertThrows(IllegalArgumentException.class, new Executable() {
            @Override
            public void execute() throws Throwable {
                Factorial.fact(-1);
            }
        });
    }
}

使用函数式编程风格,所有单方法接口都可以简写如下:

@Test
void testNegative() {
    assertThrows(IllegalArgumentException.class, () -> Factorial.fact(-1));
}

条件测试

在运行测试的时候,有些时候,我们需要排除某些 @Test 方法,不要让它运行,这时,我们就可以给它标记一个 @Disabled:

@Disabled
@Test
void testBug101() {
    // 这个测试不会运行
}

为什么我们不直接注释掉 @Test ,而是要加一个 @Disabled ?这是因为注释掉 @Test ,JUnit 就不知道这是个测试方法,而加上@Disabled,JUnit 仍然识别出这是个测试方法,只是暂时不运行。它会在测试结果中显示:

Tests run: 68, Failures: 2, Errors: 0, Skipped: 5

类似 @Disabled 这种注解就称为条件测试,JUnit 根据不同的条件注解,决定是否运行当前的 @Test 方法。我们来看一个例子:

public class Config {
    public String getConfigFile(String filename) {
        String os = System.getProperty("os.name").toLowerCase();
        if (os.contains("win")) {
            return "C:\\" + filename;
        }
        if (os.contains("mac") || os.contains("linux") || os.contains("unix")) {
            return "/usr/local/" + filename;
        }
        throw new UnsupportedOperationException();
    }
}

我们想要测试 getConfigFile() 这个方法,但是在 Windows 上跑,和在 Linux 上跑的代码路径不同,因此,针对两个系统的测试方法,其中一个只能在 Windows 上跑,另一个只能在 Mac/Linux 上跑:

@Test
void testWindows() {
    assertEquals("C:\\test.ini", config.getConfigFile("test.ini"));
}

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

因此,我们给上述两个测试方法分别加上条件如下:

@Test
@EnabledOnOs(OS.WINDOWS)
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"));
}

@EnableOnOs 就是一个条件测试判断。

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

  • 不在 Windows 平台执行的测试,可以加上 @DisabledOnOs(OS.WINDOWS) :
@Test
@DisabledOnOs(OS.WINDOWS)
void testOnNonWindowsOs() {
    // TODO: this test is disabled on windows
}
  • 只能在 Java 9 或更高版本执行的测试,可以加上 @DisabledOnJre(JRE.JAVA_8):
@Test
@DisabledOnJre(JRE.JAVA_8)
void testOnJava9OrAbove() {
    // TODO: this test is disabled on java 8
}
  • 只能在 64 位操作系统上执行的测试,可以用 @EnabledIfSystemProperty 判断:
@Test
@EnabledIfSystemProperty(named = "os.arch", matches = ".*64.*")
void testOnlyOn64bitSystem() {
    // TODO: this test is only run on 64 bit system
}
  • 需要传入环境变量 DEBUG=true 才能执行的测试,可以用 @EnabledIfEnvironmentVariable :
@Test
@EnabledIfEnvironmentVariable(named = "DEBUG", matches = "true")
void testOnlyOnDebugMode() {
    // TODO: this test is only run on DEBUG=true
}

参数化测试

如果待测试的输入和输出是一组数据,可以把测试数据组织起来用不同的测试数据调用相同的测试方法。
参数化测试和普通测试稍微不同的地方在于,一个测试方法需要接收至少一个参数,然后,传入一组参数反复运行。

JUnit 提供了一个 @ParameterizedTest 注解,用来进行参数化测试。
假设我们想对 Math.abs() 进行测试,先用一组正数进行测试:

@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 。

实际的测试场景往往没有这么简单。假设我们自己编写了一个 StringUtils.capitalize() 方法,它会把字符串的第一个字母变为大写,后续字母变为小写:

public class StringUtils {
    public static String capitalize(String s) {
        if (s.length() == 0) {
            return s;
        }
        return Character.toUpperCase(s.charAt(0)) + s.substring(1).toLowerCase();
    }
}

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

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

现在问题来了:参数如何传入?
最简单的方法是通过 @MethodSource 注解,它允许我们编写一个同名的静态方法来提供测试参数:

@ParameterizedTest
@MethodSource
void testCapitalize(String input, String result) {
    assertEquals(result, StringUtils.capitalize(input));
}

static List<Arguments> testCapitalize() {
    return List.of( // arguments:
            Arguments.of("abc", "Abc"), //
            Arguments.of("APPLE", "Apple"), //
            Arguments.of("gooD", "Good"));
}

上面的代码很容易理解:静态方法 testCapitalize() 返回了一组测试参数,每个参数都包含两个 String ,正好作为测试方法的两个参数传入。如果静态方法和测试方法的名称不同,@MethodSource 也允许指定方法名。但使用默认同名方法最方便。

另一种传入测试参数的方法是使用 @CsvSource ,它的每一个字符串表示一行,一行包含的若干参数用 , 分隔,因此,上述测试又可以改写如下:

@ParameterizedTest
@CsvSource({ "abc, Abc", "APPLE, Apple", "gooD, Good" })
void testCapitalize(String input, String result) {
    assertEquals(result, StringUtils.capitalize(input));
}

如果有成百上千的测试输入,那么,直接写 @CsvSource 就很不方便。这个时候,我们可以把测试数据提到一个独立的 CSV 文件中,然后标注上 @CsvFileSource :

@ParameterizedTest
@CsvFileSource(resources = { "/test-capitalize.csv" })
void testCapitalizeUsingCsvFile(String input, String result) {
    assertEquals(result, StringUtils.capitalize(input));
}

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

apple, Apple
HELLO, Hello
JUnit, Junit
reSource, Resource
posted @ 2023-01-13 16:49  HopeLive  阅读(123)  评论(0)    收藏  举报