Java单元测试:JUnit5入门和基本用法完整指南

2021年3月8日15:43:38 发表评论 73 次浏览

本文概述

前言

JUnit5测试教程

你有没有编写单元测试的习惯?写一个函数就测试一下?如果有,这是一个好习惯,我本人包含接触到的一些程序员并没有这个习惯。单元测试的代码并不难,相信大家都会,写单元测试也不是特别耗时间,但是它可以帮你确保你写的代码80%左右都是正确的——当然需要你对测试的代码有一个良好的覆盖——如果不是很懂,可以参考我的上一篇文章:关于软件测试-单元测试中的路径覆盖,本文举的例子也可能会使用路径覆盖。

我记得我刚刚开始编程的时候,想用socket写一个聊天的简单程序,就是正常这样写代码:按照自己学的,网上看的,参考API文档,但是还是写得很烂,错误非常多,差强人意,不大能用——其中我并没有使用测试,只是静态分析改来改去。原因一个是当时对编程的内存模型还不够了解,另一个是还不明白调试和测试的意义,以及测试驱动开发(这个话题会在另外的文章讨论一下)。

调试和测试是编程人员的两大利器,调试就是IDE中的debug,它可以帮助我们动态分析或跟踪bug,而测试就是编写代码测试其它代码。有人说:据我检查和分析推理,这个函数运行绝对正确——但是所有这些分析都比不上运行起来实际,所以,要记住任何代码都不要忘记写一下测试用例。

本文讨论的是Java中的单元测试,Java中普遍使用JUnit进行单元测试,而JUnit5是它的最新版本,如果你之前用JUnit3或JUnit4,那么你可以尝试升级到JUnit5使用。本文讨论的内容主要包括JUnit5的安装、以及关于如何使用JUnit5编写测试用例,包括使用一些JUnit提供的注解、断言等等。

JUnit5简介

JUnit5主要由三个子项目组成:JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage,其中:

  • JUnit Platfrom:也就是JUnit平台,它用于在JVM上运行测试框架,提供控制台运行器用于在命令行中启动,并提供在JUnit4环境中运行平台上的任何TestEngine;另外它主要提供一个标准的TestEngine API,也就是JUnit Jupiter和JUnit Vintage实现了这个API。
  • JUnit Jupiter:提供编程模块和扩展模块,提供TestEngine,用于运行基于JUnit Jupiter的测试。
  • JUnit Vintage:提供TestEngine,用于运行Junit3和Junit4的测试。

安装JUnit5

这里使用的IDE是IDEA,先创建一个maven项目,这里使用的maven版本是3.5.4,其中你要检查POM文件,确保maven-surefire-plugin至少为2.22.0版本,我这里使用的是2.22.1版本,这里使用的JUnit5版本是5.5.7最新版。

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-engine</artifactId>
    <version>5.7.0</version>
    <scope>test</scope>
</dependency>

为了验证你当前安装和使用JUnit5是否正确,你可以写一个简单的测试用例验证一下,如下:

public class AppTest {

    @Test
    @DisplayName("shouldEquals3")
    public void test() {
        assertEquals(3, 3);
    }

}

JUnit5注解、测试类和方法

JUnit5提供以下注解:

  • @Test:标记一个方法为简单测试方法,最常用的注解。
  • @ParameterizedTest:标记一个方法为参数化测试方法,这个方法可以批量定义数据输入集和预期输出集。
  • @RepeatedTest:标记为重复测试方法,也就是可以运行多次。
  • @TestFactory:标记方法为动态测试工厂。
  • @TestTemplate:表示方法是测试用例的模板,设计为根据注册提供者返回的调用上下文的数量多次调用。
  • @TestMethodOrder:用于为带注释的测试类配置测试方法执行顺序。
  • @TestInstance:用于为带注释的测试类配置测试实例生命周期。
  • @DisplayName:声明测试类或测试方法的自定义显示名称。
  • @DisplayNameGeneration:声明测试类的自定义显示名称生成器。
  • @BeforeEach:表示注释的方法应该在当前类中的每个@Test、@RepeatedTest、@ParameterizedTest或@TestFactory方法之前执行。
  • @AfterEach:表示带注释的方法应该在当前类中的每个@Test、@RepeatedTest、@ParameterizedTest或@TestFactory方法之后执行。
  • @BeforeAll:表示注释的方法应该在当前类中的所有@Test、@RepeatedTest、@ParameterizedTest和@TestFactory方法之前执行。
  • @AfterAll:表示注释的方法应该在当前类中的所有@Test、@RepeatedTest、@ParameterizedTest和@TestFactory方法之后执行。
  • @Nested:表示带注释的类是一个非静态嵌套测试类。@BeforeAll和@AfterAll方法不能直接在@Nested测试类中使用,除非使用“per-class”的测试实例生命周期。
  • @Tag:用于在类或方法级别声明用于筛选测试的标记。
  • @Disabled:用于禁用测试类或测试方法。
  • @Timeout:用于测试、测试工厂、测试模板或生命周期方法失败,如果其执行超过给定的持续时间。
  • @ExtendWith:用于声明性地注册扩展。
  • @RegisterExtension:用于通过字段以编程方式注册扩展。
  • @TempDir:用于在生命周期方法或测试方法中通过字段注入或参数注入提供临时目录。

JUnit对于测试类的规定为:任意顶级类、静态成员类或@Nested修饰的类至少有一个测试方法,但是不能是抽象类,并且必须只有一个构造函数。

JUnit测试方法:任意被@Test、@ParameterizedTest、@RepeatedTest、@TestFactory、@TestTemplate修饰的实例方法。

JUnit生命周期方法:任意被@BeforeEach、@AfterEach、@BeforeAll、@AfterAll修饰的方法。

另外,测试方法和生命周期方法不能是抽象的,也不能返回值,测试类、测试方法和生命周期方法不需要是公共的,但它们也不能是私有的。

下面是测试方法注解和生命周期注解的用法示例:

public class StartTest {

    @Test
    @DisplayName("shouldEquals3")
    public void test() {
        assertEquals(3, 3);
    }

    @BeforeAll
    public static void beforeAll() {
        System.out.println("before all methods");
    }

    @BeforeEach
    public void beforeEach() {
        System.out.println("before each method");
    }

    @AfterEach
    public void afterEach() {
        System.out.println("after each method");
    }

    @AfterAll
    public static void afterAll() {
        System.out.println("after all methods");
    }

    @Test
    @DisplayName("test 3 equals 2")
    public void testEquals() {
        assertEquals(3, 2);
    }

    @ParameterizedTest
    @ValueSource(strings = { "racecar", "radar", "able was I ere I saw elba" })
    public void testArray(String s) {
        assertTrue(s.contains("a"));
    }

    @RepeatedTest(3)
    public void repeatedTest() {
        System.out.println("repeat test");
    }

    @Test
    public void sampleTest() {
        Assumptions.assumeTrue("abc".contains("d"));
    }

    @Test
    public void shouldFailed() {
        fail("should fail");
    }

}

其中,如果你要使用@ParameterizedTest注解,需要另外添加maven依赖,使用这个注解需要使用unit-jupiter-params,如下:

<dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter-params</artifactId>
      <version>5.7.0</version>
      <scope>test</scope>
    </dependency>

@DisplayName

@DisplayName(str)用于注释方法,里面的str表示在运行测试的时候显示,你可以显示任意字符或表情。

@DisplayNameGeneration用于修饰一个测试类,用于指定一个显示名生成器,Jupiter提供的生成器如下:

  • Standard:默认的标准生成器。
  • Simple:该生成器删除无参数方法的尾括号。
  • ReplaceUnderscores:用空格替换下划线。
  • IndicativeSentences:通过连接测试的名称和外围类生成完整的句子。

使用的方法如下:

@DisplayNameGeneration(DisplayNameGenerator.Simple.class)
public class AppTest {
}

另外,你可以在src/test/resources/junit-platform.properties)中配置名称生成器的默认类,例如:

junit.jupiter.displayname.generator.default = \
org.junit.jupiter.api.DisplayNameGenerator$Simple

JUnit在决定为一个类或方法显示名称的时候,按照以下顺序和规则进行:

  • @DisplayName标注的值,如果有的话
  • 通过调用@DisplayNameGeneration注释中指定的DisplayNameGenerator(如果有的话)
  • 通过调用通过配置参数配置的默认DisplayNameGenerator(如果存在的话,也就是junit-platform.properties)
  • 通过调用org.junit.jupiter.api.DisplayNameGenerator.Standard

断言、假设和禁用测试

断言的意思就是断定某个判断是正确的,然后运行JUnit测试一下是不是真的是正确的。JUnit提供很多断言方法,而这些方法都是static方法,其中JUnit5很多断言或假设都可能会用到Lambda表达式,所以你最好最低有Java 8支持。

详细的断言和假设用法以及一些注解,你可以参考JUnit 5最新的API文档,最新地址为: HYPERLINK "https://junit.org/junit5/docs/current/api/" https://junit.org/junit5/docs/current/api/。

下面给出一些断言使用的示例:

public class AssertionsDemo {

    private Calculator calculator = new Calculator();

    @Test
    void standardAssertions() {
        assertEquals(3, calculator.sub(7, 4));
        assertEquals(8, calculator.add(2, 6));
        assertTrue("abc".contains("b"), () -> "current string contains character b");
    }

    @Test
    void groupAssertions() {
        assertAll("calculator",
                () -> assertEquals(32, calculator.add(16, 16)),
                () -> assertEquals(40, calculator.sub(100, 60)),
                () -> assertEquals(16, calculator.mul(2, 8)),
                () -> assertEquals(3, calculator.div(27, 9)));
    }

    @Test
    void dependentAssertions() {
        assertAll("calculators",
                () -> {
                    Calculator c = new Calculator();
                    assertNotNull(c);
                    assertAll("calculator 1",
                            () -> assertEquals(23, c.add(20, 3)),
                            () -> assertEquals(14, c.sub(30, 16))
                    );
                },
                () -> {
                    Calculator c = new Calculator();
                    assertNotNull(c);
                    assertAll("calculator 2",
                            () -> assertEquals(40, c.mul(5, 8)),
                            () -> assertEquals(4, c.div(20, 5))
                            );
                }
                );
    }

    @Test
    void exceptionTest() {
        Exception exception = assertThrows(ArithmeticException.class, () -> calculator.div(1, 0));
        assertEquals("/ by zero", exception.getMessage());
    }

    @Test
    void timeoutNotExceeded() {
        assertTimeout(Duration.ofMinutes(3), () -> {
            Thread.sleep(1000);
        });
    }

}

另外,你可以可以使用一些第三方的断言库,例如AssertJ、Hamcrest和Truth等,结合JUnit5使用,你可以获得更丰富的测试功能。

假设(Assumption)意思是假设一个判断为真,然后使用JUnit5测试,看看这个假设是否为真,和断言不同的是,测试结果如果为false,只会提示ignored,而断言如果是错的,那么会提示failed。

假设的使用和断言的用法是类似的,很多都是相类似的方法和参数,这里给出一些简单的例子:

public class AssumptionsDemo {

    @Test
    void simpleTest() {
        assumeTrue("abc".contains("b2"));
    }

    @Test
    void groupTest() {
        assumingThat(1 < 2, () -> {
            assumeTrue(2 < 3);
        });
    }

}

@Disabled(msg)注解可以在方法和类上使用,被这个注解修饰的类或方法会被忽略,一般使用场景是:如果你的程序中有一些bug,必须修复这些bug才能运行这些类或函数的测试,那么你有必要使用这个注解。其中可以带一些字符串信息,也可以不带。

条件测试和标签

ExecutionCondition API提供条件测试的功能,你可以在包org.junit.jupiter.api.condition中找到相关的条件测试,并有相应的注解,每个条件测试的注解都有一个disableReason的属性,这个属性说明disable的理由。

其中,JUnit 5提供的条件测试注解有:

  • 操作系统条件:@EnabledOnOs和DisabledOnOs,在指定系统上启用或禁用类或方法测试。
  • Java运行时条件注解:@EnabledOnJre和@DisabledOnJre,在JRE中禁用或启动测试;@EnabledForJreRange和@DisabledForJreRange,其中JRE.JAVA_8为最小值,JRE.OTHER为最大值,在某个JRE版本范围内禁用或启动测试。
  • 系统属性条件:@EnabledIfSystemProperty和@DisabledIfSystemProperty,根据named的JVM系统属性值禁用或启动测试,通过matches属性提供的值将被解释为正则表达式。
  • 环境变量条件:@EnabledIfEnvironmentVariable 注解启用测试,而注解@DisabledIfEnvironmentVariable启动测试,其中named是环境变量的名称,matches是一个正则表达式。
  • 自定义条件:容器或测试可以通过@EnabledIf和@DisabledIf注释根据方法的boolean返回来启用或禁用。方法通过其名称提供给注释,如果位于测试类之外,则通过其完全限定名提供给注释。如果需要,条件方法可以采用ExtensionContext类型的单个参数。修饰类的时候,方法需要是static的。

下面是具体的使用例子:

public class ConditionsDemo {

    @Nested
    class OperationSystemConditionsDemo {

        @Test
        @EnabledOnOs(OS.WINDOWS)
        void testOnWind() {
            System.out.println("windows system");
        }

        @Test
        @EnabledOnOs({OS.LINUX, OS.MAC, OS.SOLARIS})
        void testNotOnWind() {
            System.out.println("unix system");
        }

    }

    @Nested
    class RuntimeConditionsDemo {

        @Test
        @EnabledOnJre(JRE.JAVA_8)
        void testOnJava8() {
            System.out.println("java 8");
        }

        @Test
        @DisabledOnJre({JRE.JAVA_9, JRE.JAVA_12})
        void testNotOnJava8OrJava12() {
            System.out.println("not on java 9 or java 12");
        }

    }

    @Nested
    class SystemPropertyConditionsDemo {

        @Test
        @EnabledIfSystemProperty(named = "os.arch", matches = ".*64.*")
        void onlyOn64BitArchitectures() {
            System.out.println("64 bit");
        }
        @Test
        @DisabledIfSystemProperty(named = "ci-server", matches = "true")
        void notOnCiServer() {
            System.out.println("not ci-server");
        }

    }

    @Nested
    class EnvironmentValuesConditionsDemo {
        @Test
        @EnabledIfEnvironmentVariable(named = "ENV", matches = "staging-server")
        void onlyOnStagingServer() {
            System.out.println("staging server");
        }
        @Test
        @DisabledIfEnvironmentVariable(named = "ENV", matches = ".*development.*")
        void notOnDeveloperWorkstation() {
            System.out.println("develop");
        }
    }

    @Nested
    class CustomConditionsDemo {
        @Test
        @EnabledIf("customCondition")
        void enabled() {
            System.out.println("custom enabled");
        }
        @Test
        @DisabledIf("customCondition")
        void disabled() {
            System.out.println("custom disabled");
        }
        boolean customCondition() {
            return true;
        }
    }

}

另外,测试类和方法可以通过@Tag注释标记。这些标记可以稍后用于过滤测试发现和执行。

测试执行顺序

JUnit中的测试函数执行顺序默认按照其所在位置进行排序,如果你需要重新指定这些测试函数的执行顺序,那么你需要使用@TestMethodOrder指定一个函数执行顺序的实现类,JUnit提供的实现类有:

  • DispalyName:根据测试方法的显示名称按字母数字排序。
  • MethodName:根据测试方法的方法名和形式参数列表,按字母数字排序。
  • OrderAnnotation:基于通过@Order注释指定的值对测试方法进行数字排序。
  • Random:顺序测试方法是伪随机的,并支持自定义种子的配置。

下面是使用OrderAnnotation的例子,其它的类似,但是不用使用@Order注解。

@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class MethodOrderDemo {

    @Test
    @Order(2)
    void testAbs() {
        assertEquals(1, Math.abs(-1), "abs(-1) = 1");
    }

    @Test
    @Order(1)
    void testPow() {
        assumeTrue(27 == Math.pow(3, 3));
    }

    @Test
    @Order(3)
    void testString() {
        assertAll("math", () -> {
            assertEquals(2, Math.abs(-2));
            assertEquals(2, Math.log10(100));
        });
    }

}

另外,像上面使用junit-platform.properties一样,你也可以在这里面指定默认的顺序器实现类,例如:

junit.jupiter.testmethod.order.default = \
org.junit.jupiter.api.MethodOrderer$OrderAnnotation

测试实例的生命周期

JUnit默认为每个测试方法创建一个新的测试类实例,也就是说,默认运行时如果你运行多个测试方法,那么其对应的实例对象是不一样的。如果你想让所有的测试方法共享一个实例,那么你可以在对应的类上面使用注解@TestInstance(Lifecycle.PER_CLASS),其中PER_CLASS表示所有方法共享一个实例,PER_METHOD表示所有方法都创建一个新的实例。

同样地,你也可以在配置文件unit-platform.properties中全局设置,如:

junit.jupiter.testinstance.lifecycle.default = per_class

依赖注入

JUnit中默认的测试类和方法是不带参数的,除了一些特殊的情况,例如JUnit允许我们的在类构造函数和方法中使用一些特定的参数,例如TestInfo、RepetitionInfo和TestReporter等,其它一些更为复杂的参数你可以参考JUnit官方的API文档。

下面是一个使用例子:

@DisplayName("Dependency Injection Demo")
public class DIDemo {

    // TestInfo: DI for constructor
    public DIDemo (TestInfo testInfo) {
        System.out.println(testInfo.getDisplayName());
    }

    // TestInfo: DI for method
    @Tag("TestTag")
    @Test
    void simpleTest(TestInfo testInfo) {
        System.out.println(testInfo.getTestMethod() + " =>[tag] " + testInfo.getTags().size());
    }

    // TestReporter
    @Test
    void testReporter (TestReporter testReporter) {
        Map<String, String> p = new HashMap<>();
        p.put("hume", "causality");
        p.put("kant", "transcendental categories");
        p.put("descartes", "thinking");
        testReporter.publishEntry(p);
    }

}

@ParameterizedTest:最重要的参数化测试

JUnit的内容很多,你不一定学的完,但是最基本还是会@Test的,使用这个注解基本可以实现测试驱动开发、以及路径覆盖。但是,如果你会@ParameterizedTest,那简直是如虎添翼!它可以让你批量输入数据。

使用它的理由是:通常我们可以对一个路径使用一个测试用例进行覆盖,但是一个用例的输入值还是比较单一的,例如:如果当前路径输入数据是一个int,那么我们得考虑它为最大值、最小值、0以及上下溢出的情况,使用@ParameterizedTest则可以一次性指定所有这些输入数据;另外,如果我们执行对每个路径仅仅使用一个数据进行覆盖,使用@ParameterizedTest同样也可以一次性指定所有路径的输入数据。

下面,我们来详细讨论@ParameterizedTest注解的用法,如果其它注解都忘了,没关系,我建议你一定要记得这个注解的用法,多用,有益身心!

首先,要在JUnit5中使用参数化测试,你需要添加junit-jupiter-params的依赖,这个在上面刚开始的时候已经给出了。

1、测试方法参数

使用@ParameterizedTest和@Test是一样的,在一个方法上添加这个注解即可,第二个是指定数据集,例如可以使用@ValueSource或@CsvSource注解指定,第三个是指定测试方法参数,参数用于接收数据集中的数据。

一般来说JUnit默认是将数据集中的格式化数据(源数据)和方法的参数一一对应的,但是你也可以使用聚合的方法,将源数据聚合到一个类中传递给方法。

具体来说,参数化测试方法必须根据以下规则声明形式参数。

  • 必须首先声明0个或多个索引参数。
  • 接下来必须声明0个或多个聚合器。
  • ParameterResolver提供的0个或多个参数必须最后声明。

2、参数源:数据集

JUnit提供以下指定数据集的注解:

  • @ValueSource:支持所有基本数据类型、以及String和Class类型的数据,该注解是最简单的一个,但是只支持一维的数据。
  • Null和空数据:@NullSource指定null参数,@EmptySource指定空参数,但仅限数据和一些数据结构,@NullAndEmptySource,前两个注解的组合。如果你不想使用注解指定空数据,那么你可以使用@ValueSource显式指定。
  • @EnumSource:指定枚举数据类型的数据,首先value指定类型,然后函数参数为枚举类型,除非函数参数类型是value类型的父类或接口。
  • @MethodSource:指定一个函数,该函数需要是static的,函数的返回值需要是Stream或Arguments类型。
  • @CsvSource:允许你用逗号分隔的值(即字符串字面量)来表示参数列表。
  • @CsvFileSource:@CsvFileSource允许你使用来自类路径或本地文件系统的CSV文件,CSV文件中的每一行都会导致对参数化测试的一次调用。
  • @ArgumentsSource:@ArgumentsSource可以用来指定一个自定义的,可重用的ArgumentsProvider。注意ArgumentsProvider的实现必须声明为顶级类或静态嵌套类。

下面是用法示例:

public class ParameterizedTestDemo {

    // 1. @ValueSource

    @ParameterizedTest
    @ValueSource(ints = {10, 3, 8, 4, 7})
    void testDaysNumber(int day) {
        assertTrue(day > 0 && day < 8);
    }

    @ParameterizedTest
    @ValueSource(floats = {2.3f, 3.4f, 2.03f, 7.8f})
    void testFloats(float f) {
        assertTrue(f > 3 && f < 6);
    }

    // 2. null & empty

    @ParameterizedTest
    @NullSource
    @EmptySource
    @NullAndEmptySource
    void testEmpty(String t) {
        assertTrue(t == null || t.isEmpty());
    }

    @ParameterizedTest
    @NullSource
    @ValueSource(strings = {" ", "  ", "\n", "\t"})
    void testEmptyWithValueSource(String t) {
        assertTrue(t == null || t.trim().isEmpty());
    }

    // 3. @MethodSource

    @ParameterizedTest
    @MethodSource("getFromStream")
    void testStreamMethod(String t) {
        assertNotNull(t);
    }

    static Stream<String> getFromStream() {
        return Stream.of("a", "b", null, " ", "\n");
    }

    @ParameterizedTest
    @MethodSource("getFromArguments")
    void testArguments(int a, String t, List<String> strings) {
        assertTrue(a < 10);
        assertNotNull(t);
        assertEquals(2, strings.size());
    }

    static Stream<Arguments> getFromArguments() {
        return Stream.of(
                Arguments.of(3, "abc", Arrays.asList("r", "b")),
                Arguments.of(10, null, Arrays.asList("bs", "op", "oop")),
                Arguments.of(7, "q", Arrays.asList("a", "2"))
        );
    }

    // 4. @CsvSource

    @ParameterizedTest
    @CsvSource({
            "1,     'abc',  ,   2.3f",
            "3,     try,    b,  7.0f",
            "2,     ozm,    d,  9.0f"
    })
    void testCSV(int a, String str, String text, float f) {
        assertTrue(a < 3);
        assertEquals(3, str.length());
        assertNotNull(text);
        assertTrue(f < 10);
    }

    @ParameterizedTest
    @CsvSource({
            "3,     2,      9",
            "1,     5,      6",
            "8,     3,      512"
    })
    void testMath(int a, int b, int c) {
        assertEquals(c, Math.pow(a, b));
    }

}

对于聚合参数就不说了,你也可以参考JUnit文档或相关文章,聚合参数就是将零散的参数用一个对象封装,方便使用对象的方式访问数据。

临时目录@TempDir

最后说一个@TempDir,这个你可能会用到,该注解用于修饰一个java.nio.file.Path或java.io.File类型的变量,这个变量可以是类的全局变量或函数变量

总结

先写到这里了,其它更多JUnit5的详细内容你可以参考JUnit的官方参考文档或Javadoc,但是本文讨论到的已经基本够用了,就我们日常用的就是@Test和@ParameterizedTest了。即使其它一些功能或注解不会用,只要你会软件测试的基本原理:对代码的某一个方面进行覆盖,如执行路径覆盖、语句覆盖、条件覆盖等等,仅仅会@Test你都能完整实现得了。而本文主要是让你对Junit5单元测试的相关内容有一个整体的了解。

今天是2021年的第一天,新的一年,祝大家新年快乐!年龄逐渐增加,甚是彷徨!

下一篇我们继续讨论测试和软件开发的相关内容:测试驱动开发(TDD)。

木子山

发表评论

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: