JUnit 5 教程 之 基础篇

JUnit 5 作为新一代的 Java 单元测试框架,提供很多改进。例如对比 JUnit4JUnit5 的官网,JUnit5 的设计更加简约与时尚,至少不会抗拒阅读的程度了(像破烂一样的网站,看了整个人都难受,不影响效率?不存在的)

image

而且,除此外,他的文档使用了 Asciidoc, 相对于markdown复杂,主要是它还支持具有包含另一个文件内容,这对于写API文档来说挺重要的,有兴趣可以了解下~

Okay, 结束吐槽,让我来看看 JUnit5 到底带来了哪些变化吧

JUnit 5 是什么?

与以往的版本不同,JUnit5 由三个模块模版组成 JUnit Platform + JUnit Jupiter + JUnit Vintage

  • JUnit Platform:运行测试框架的基础服务,定义了一套API,任何实现这套API的测试引擎,都能运行在这之上
  • JUnit Jupiter:一系列用于编写JUnit5测试或者扩展的组合,同时他的子项目提供了JUnit5测试引擎
  • JUnit Vintage:提供 JUnit3 和 JUnit4 的测试引擎

三分钟教程

环境搭建

  1. 创建你的项目(建议Spring Boot),简单的勾选几个依赖


    image
  2. 添加 JUnit5 的依赖(spring boot 2.2 中已默认是Junit5,不需要额外加,详见WIKI),

    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>${latest-version}</version>
      <scope>test</scope>
    </dependency>

org.junit.jupiter:junit-jupiter已包含了 JUnit Platform,不需要额外声明依赖,一个就够了

image

第一个测试用例

  1. 创建一个待测试的工具类
public class TimeUtils {
    public static String hello(Instant now) {
        return "现在时间是:" + now.toString();
    }
}
  1. 创建测试用例
class TimeUtilsTest {
    @Test
    void hello() {
        Instant now = Instant.now();
        String expect = "现在时间是:" + now.toString();
        assertEquals(expect, TimeUtils.hello(now));
    }
}
  1. 运行测试用例,如果你使用idea,那么直接点旁边的运行按钮,或者使用其它编辑器的功能测试,当然,你还可以选择通过命令行,下载junit-platform-console-standalone,并运行它(不懂),另一种是mvn test运行测试

更多食用方案

别名

测试的Class可以通过添加@DisplayName(),添加别名

@DisplayName("时间工具类测试")
class TimeUtilsTest {}

也可以使用@DisplayNameGeneration(),进行更多的配置

@DisplayNameGeneration(TimeUtils2Test.ReplaceUnderscores.class)
class TimeUtils2Test {
    @Test
    void hello() {
        Instant now = Instant.now();
        String expect = "现在时间是:" + now.toString();
        assertEquals(expect, TimeUtils.hello(now));
    }
    static class ReplaceUnderscores extends DisplayNameGenerator.ReplaceUnderscores {
        @Override
        public String generateDisplayNameForClass(Class<?> testClass) {
            return "哈哈哈";
        }
    }
}

断言、假设

测试中核心之一,用于判断是否执行成功,在JUnit5中增加了些对lambdas的支持,例如:

    @Test
    void asserts() {
        assertEquals(1,2, () -> "1要是1");
    }

另外,还增加了假设

    @Test
    void assume() {
        assumingThat("DEV".equals(System.getenv("ENV")),
                () -> {
                    // 如果不为true这里将不执行
                    assertEquals(1, 1);
                });

        assumeTrue("DEV".equals(System.getenv("ENV")),
                () -> "Aborting test: not on developer workstation");
        // 如果不为true这里将不执行
    }

禁用

添加@Disabled()可以禁用测试,这个意义在于某一测试用例遇到问题,临时不执行,等待问题修复后再次使用的

@Disabled("Disabled 因为重复")
class TimeUtilsCopyTest {}

测试执行条件

通过添加 @EnabledOnOs 或者 @DisabledOnOs 来决定在某一操作系统上执行.

    @Test
    @EnabledOnOs(MAC)
    void testOnMac() {
        log.info("exec on mac");
    }
    @Test
    @EnabledOnOs({ WINDOWS, LINUX })
    void testOnOs() {
        log.info("exec on windows or linux");
    }

@EnabledOnJre 和 @DisabledOnJre 可以对java环境判断

    @Test
    @EnabledOnJre(JRE.JAVA_8)
    void testOnJava8() {
        log.info("exec on java 8");
    }

@EnabledIfSystemProperty/@DisabledIfSystemProperty 与 @EnabledIfEnvironmentVariable/@DisabledIfEnvironmentVariable 分别判断系统和环境变量,他们的匹配项支持正则表达式

@Test
@DisabledIfEnvironmentVariable(named = "ENV", matches = ".*development.*")
void notOnDeveloperWorkstation() {
    // ...
}

标签/分组

JUnit5 中支持通过 @Tag() 对测试用例进行分组,例如

    @Tag("conditional")
    @Test
    @EnabledOnOs(MAC)
    void testOnMac() {
        log.info("exec on mac");
    }
    @Tag("conditional")
    @Test
    @EnabledOnJre(JRE.JAVA_8)
    void testOnJava8() {
        log.info("exec on java 8");
    }

@Tag() 有以下这些语法规则

  • 不能为null或者空字符串
  • 不能有空格
  • 不能包含ISO控制符
  • 不能包含保留字符(,,(,),&,|,!)

顺序

添加@TestMethodOrder(MethodOrderer.OrderAnnotation.class)与@Order(),定义测试用例的执行顺序

@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class OrderedTest {
    @Test
    @Order(2)
    void emptyValues() {
        // perform assertions against empty values
    }
    @Test
    @Order(1)
    void nullValues() {
        // perform assertions against null values
    }
    @Test
    @Order(3)
    void validValues() {
        // perform assertions against valid values
    }
}

生命周期

JUnit5 提供了4个生命周期注解 @BeforeAll @AfterAll @BeforeEach @AfterEach

  • @BeforeAll:在所有的 @Test @RepeatedTest @ParameterizedTest @TestFactory 之前执行
  • @BeforeEach:在每个测试用例前执行
  • @AfterAll @AfterEach:与before类似,在测试用例之后执行

例如:

@Slf4j
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class LifecycleTest {
    int num = 0;
    @BeforeAll
    static void initAll() {
        log.error("initAll");
    }
    @BeforeEach
    void init() {
        log.error("init");
    }
    @Test
    @Order(1)
    void doTest1() {
        log.error("num is " + num);
        num = 1;
        log.error("doTest1");
    }
    @Test
    @Order(2)
    void doTest2() {
        log.error("num is " + num);
        num = 2;
        log.error("doTest1");
    }
}

除此外,还有@TestInstance()配置,见上面的例子,这个存在两个模式

  • PER_METHOD:每个测试用例执行前,都会创建一个实例(默认,与junit4一致)
  • PER_CLASS:每个类的测试用例执行前,创建统一的实例

上面的例子中,得到的log为:

13:58:03.477 [main] ERROR com.jiangtj.example.junit5.LifecycleTest - initAll
13:58:03.485 [main] ERROR com.jiangtj.example.junit5.LifecycleTest - init
13:58:03.487 [main] ERROR com.jiangtj.example.junit5.LifecycleTest - num is 0
13:58:03.487 [main] ERROR com.jiangtj.example.junit5.LifecycleTest - doTest1
13:58:03.494 [main] ERROR com.jiangtj.example.junit5.LifecycleTest - init
13:58:03.495 [main] ERROR com.jiangtj.example.junit5.LifecycleTest - num is 1
13:58:03.495 [main] ERROR com.jiangtj.example.junit5.LifecycleTest - doTest1

doTest1() 的执行,影响到num属性的值,而默认模式下则不会

嵌套

@Nested() 可以更好的表达测试用例间的关系,例如官方的例子

@DisplayName("A stack")
class TestingAStackDemo {

    Stack<Object> stack;

    @Test
    @DisplayName("is instantiated with new Stack()")
    void isInstantiatedWithNew() {
        new Stack<>();
    }

    @Nested
    @DisplayName("when new")
    class WhenNew {

        @BeforeEach
        void createNewStack() {
            stack = new Stack<>();
        }

        @Test
        @DisplayName("is empty")
        void isEmpty() {
            assertTrue(stack.isEmpty());
        }

        @Test
        @DisplayName("throws EmptyStackException when popped")
        void throwsExceptionWhenPopped() {
            assertThrows(EmptyStackException.class, stack::pop);
        }

        @Test
        @DisplayName("throws EmptyStackException when peeked")
        void throwsExceptionWhenPeeked() {
            assertThrows(EmptyStackException.class, stack::peek);
        }

        @Nested
        @DisplayName("after pushing an element")
        class AfterPushing {

            String anElement = "an element";

            @BeforeEach
            void pushAnElement() {
                stack.push(anElement);
            }

            @Test
            @DisplayName("it is no longer empty")
            void isNotEmpty() {
                assertFalse(stack.isEmpty());
            }

            @Test
            @DisplayName("returns the element when popped and is empty")
            void returnElementWhenPopped() {
                assertEquals(anElement, stack.pop());
                assertTrue(stack.isEmpty());
            }

            @Test
            @DisplayName("returns the element when peeked but remains not empty")
            void returnElementWhenPeeked() {
                assertEquals(anElement, stack.peek());
                assertFalse(stack.isEmpty());
            }
        }
    }
}

我们可以清晰的看到他们之间的关系

image

重复测试

@RepeatedTest() 执行多次测试,支持name修改名称(具体见官网,觉得没多大意义),另外可以在方法中获取repetitionInfo参数,用于判断当前的执行情况(JUnit5支持注入参数,后续详说)

@Slf4j
class RepeatedTestsDemo {
    @RepeatedTest(2)
    void repeatedTest() {
        log.info("done!");
    }
    @RepeatedTest(2)
    void repeatedTest2(RepetitionInfo repetitionInfo) {
        int currentRepetition = repetitionInfo.getCurrentRepetition();
        int totalRepetitions = repetitionInfo.getTotalRepetitions();
        log.info(String.format("About to execute repetition %d of %d", //
                currentRepetition, totalRepetitions));
    }
}

参数测试

@ParameterizedTest 很实用的注解,需要junit-jupiter-params依赖(我们已经添加了)

它主要是配置@xxxSource,注入参数,以完成测试,参数的注入方式有多种

数据源

@ValueSource 注入String内容,这是最常用的

    @ParameterizedTest
    @ValueSource(strings = { "racecar", "radar", "able was I ere I saw elba" })
    void palindromes(String candidate) {
        log.error(candidate);
    }

@EnumSource 注入枚举类

    @ParameterizedTest
    @EnumSource(TimeUnit.class)
    void testWithEnumSource(TimeUnit timeUnit) {
        log.error(timeUnit.toString());
    }
    @ParameterizedTest
    @EnumSource(value = TimeUnit.class, names = { "DAYS", "HOURS" })
    void testWithEnumSourceInclude(TimeUnit timeUnit) {
        // 选择部分
        log.error(timeUnit.toString());
    }

@MethodSource 通过方法名注入(我更倾向于使用下面的@ArgumentsSource)

    @ParameterizedTest
    @MethodSource("stringProvider")
    void testWithExplicitLocalMethodSource(String argument) {
        log.error(argument);
    }
    static Stream<String> stringProvider() {
        return Stream.of("apple", "banana");
    }

    @ParameterizedTest
    @MethodSource("stringIntAndListProvider")
    void testWithMultiArgMethodSource(String str, int num, List<String> list) {
        // 多参支持
        log.error(String.format("Content: %s is %d, %s", str, num, String.join(",", list)));
    }
    static Stream<Arguments> stringIntAndListProvider() {
        return Stream.of(
                arguments("apple", 1, Arrays.asList("a", "b")),
                arguments("lemon", 2, Arrays.asList("x", "y"))
        );
    }

@CsvSource csv源支持

    @ParameterizedTest
    @CsvSource({
            "apple,         1",
            "banana,        2",
            "'lemon, lime', 0xF1"
    })
    void testWithCsvSource(String fruit, int rank) {
        log.error(fruit + rank);
    }

它也支持从文件导入,例如@CsvFileSource(resources = "/two-column.csv", numLinesToSkip = 1)

@ArgumentsSource 通过自定义的参数提供器导入

    @ParameterizedTest
    @ArgumentsSource(MyArgumentsProvider.class)
    void testWithArgumentsSource(String argument) {
        log.error(argument);
    }
    static class MyArgumentsProvider implements ArgumentsProvider {
        @Override
        public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
            return Stream.of("apple", "banana").map(Arguments::of);
        }
    }

参数转换

为了支持csv,JUnit支持了些内建的转换,详细见文档writing-tests-parameterized-tests-argument-conversion,如果转换失败,会寻找构造器或者静态构造方法(非私有)中,单String的方法,来转换对应的对象

内建的转换有必要,但后一种,我宁愿得到报错,而不是转换,隐形的转换往往会导致莫名的问题出现

所以推荐通过@ConvertWith实现参数类型间的转换

    @ParameterizedTest
    @ValueSource(strings = { "Wow,12", "radar,50"})
    void toBook(@ConvertWith(ToBookConverter.class) Book book) {
        log.error(book.toString());
    }
    static class ToBookConverter extends SimpleArgumentConverter {
        @Override
        protected Object convert(Object source, Class<?> targetType) {
            String value = String.valueOf(source);
            String[] split = value.split(",");
            return Book.of(split[0], Integer.parseInt(split[1]));
        }
    }

JUnit中也内置了些转换,如@JavaTimeConversionPattern等

除外,还可以通过@AggregateWith转换或者接收ArgumentsAccessor对象

Dynamic测试

除了常规的@Test,我们还可以通过@TestFactory来构建整个测试树

class DynamicTestsDemo {

    private final Calculator calculator = new Calculator();

    // This will result in a JUnitException!
    @TestFactory
    List<String> dynamicTestsWithInvalidReturnType() {
        return Arrays.asList("Hello");
    }

    @TestFactory
    Collection<DynamicTest> dynamicTestsFromCollection() {
        return Arrays.asList(
            dynamicTest("1st dynamic test", () -> assertTrue(isPalindrome("madam"))),
            dynamicTest("2nd dynamic test", () -> assertEquals(4, calculator.multiply(2, 2)))
        );
    }

    @TestFactory
    Iterable<DynamicTest> dynamicTestsFromIterable() {
        return Arrays.asList(
            dynamicTest("3rd dynamic test", () -> assertTrue(isPalindrome("madam"))),
            dynamicTest("4th dynamic test", () -> assertEquals(4, calculator.multiply(2, 2)))
        );
    }

    @TestFactory
    Iterator<DynamicTest> dynamicTestsFromIterator() {
        return Arrays.asList(
            dynamicTest("5th dynamic test", () -> assertTrue(isPalindrome("madam"))),
            dynamicTest("6th dynamic test", () -> assertEquals(4, calculator.multiply(2, 2)))
        ).iterator();
    }

    @TestFactory
    DynamicTest[] dynamicTestsFromArray() {
        return new DynamicTest[] {
            dynamicTest("7th dynamic test", () -> assertTrue(isPalindrome("madam"))),
            dynamicTest("8th dynamic test", () -> assertEquals(4, calculator.multiply(2, 2)))
        };
    }

    @TestFactory
    Stream<DynamicTest> dynamicTestsFromStream() {
        return Stream.of("racecar", "radar", "mom", "dad")
            .map(text -> dynamicTest(text, () -> assertTrue(isPalindrome(text))));
    }

    @TestFactory
    Stream<DynamicTest> dynamicTestsFromIntStream() {
        // Generates tests for the first 10 even integers.
        return IntStream.iterate(0, n -> n + 2).limit(10)
            .mapToObj(n -> dynamicTest("test" + n, () -> assertTrue(n % 2 == 0)));
    }

    @TestFactory
    Stream<DynamicTest> generateRandomNumberOfTests() {

        // Generates random positive integers between 0 and 100 until
        // a number evenly divisible by 7 is encountered.
        Iterator<Integer> inputGenerator = new Iterator<Integer>() {

            Random random = new Random();
            int current;

            @Override
            public boolean hasNext() {
                current = random.nextInt(100);
                return current % 7 != 0;
            }

            @Override
            public Integer next() {
                return current;
            }
        };

        // Generates display names like: input:5, input:37, input:85, etc.
        Function<Integer, String> displayNameGenerator = (input) -> "input:" + input;

        // Executes tests based on the current input value.
        ThrowingConsumer<Integer> testExecutor = (input) -> assertTrue(input % 7 != 0);

        // Returns a stream of dynamic tests.
        return DynamicTest.stream(inputGenerator, displayNameGenerator, testExecutor);
    }

    @TestFactory
    Stream<DynamicNode> dynamicTestsWithContainers() {
        return Stream.of("A", "B", "C")
            .map(input -> dynamicContainer("Container " + input, Stream.of(
                dynamicTest("not null", () -> assertNotNull(input)),
                dynamicContainer("properties", Stream.of(
                    dynamicTest("length > 0", () -> assertTrue(input.length() > 0)),
                    dynamicTest("not empty", () -> assertFalse(input.isEmpty()))
                ))
            )));
    }

    @TestFactory
    DynamicNode dynamicNodeSingleTest() {
        return dynamicTest("'pop' is a palindrome", () -> assertTrue(isPalindrome("pop")));
    }

    @TestFactory
    DynamicNode dynamicNodeSingleContainer() {
        return dynamicContainer("palindromes",
            Stream.of("racecar", "radar", "mom", "dad")
                .map(text -> dynamicTest(text, () -> assertTrue(isPalindrome(text)))
        ));
    }

}

还未看过源码,但目测@Test是由内建的转换器,转换成DynamicNode,然后再执行。使用@TestFactory,tree型的代码也是种选择,再维护上,不差于@Test的常规方案

后续还有扩展与Spring应用篇,欢迎关注我哦~

一个小疑问,JUnit5 的注解风格和 Spring 为何如此接近。。。

本文作者: Mr.J
本文链接: https://www.dnocm.com/articles/cherry/junit-5-info/
版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 144,247评论 1 305
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 61,830评论 1 258
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 95,531评论 0 214
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 41,345评论 0 183
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 49,160评论 1 260
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 38,936评论 1 178
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 30,538评论 2 275
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 29,291评论 0 168
  • 想象着我的养父在大火中拼命挣扎,窒息,最后皮肤化为焦炭。我心中就已经是抑制不住地欢快,这就叫做以其人之道,还治其人...
    爱写小说的胖达阅读 29,162评论 6 237
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 32,654评论 0 214
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 29,401评论 2 217
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 30,747评论 1 232
  • 白月光回国,霸总把我这个替身辞退。还一脸阴沉的警告我。[不要出现在思思面前, 不然我有一百种方法让你生不如死。]我...
    爱写小说的胖达阅读 24,297评论 1 33
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 27,206评论 2 213
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 31,670评论 3 213
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,661评论 0 9
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,089评论 0 169
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 33,677评论 2 233
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 33,819评论 2 237

推荐阅读更多精彩内容