JUnit 5 作为新一代的 Java 单元测试框架,提供很多改进。例如对比 JUnit4 与 JUnit5 的官网,JUnit5 的设计更加简约与时尚,至少不会抗拒阅读的程度了(像破烂一样的网站,看了整个人都难受,不影响效率?不存在的)
而且,除此外,他的文档使用了 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 的测试引擎
三分钟教程
环境搭建
-
创建你的项目(建议Spring Boot),简单的勾选几个依赖
添加 JUnit5 的依赖(spring boot 2.2 中已默认是Junit5,不需要额外加,详见WIKI),
org.junit.jupiter
junit-jupiter
${latest-version}
test
org.junit.jupiter:junit-jupiter
已包含了 JUnit Platform,不需要额外声明依赖,一个就够了
第一个测试用例
- 创建一个待测试的工具类
public class TimeUtils {
public static String hello(Instant now) {
return "现在时间是:" + now.toString();
}
}
- 创建测试用例
class TimeUtilsTest {
@Test
void hello() {
Instant now = Instant.now();
String expect = "现在时间是:" + now.toString();
assertEquals(expect, TimeUtils.hello(now));
}
}
- 运行测试用例,如果你使用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
我们可以清晰的看到他们之间的关系
重复测试
@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 stringProvider() {
return Stream.of("apple", "banana");
}
@ParameterizedTest
@MethodSource("stringIntAndListProvider")
void testWithMultiArgMethodSource(String str, int num, List list) {
// 多参支持
log.error(String.format("Content: %s is %d, %s", str, num, String.join(",", list)));
}
static Stream 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 dynamicTestsWithInvalidReturnType() {
return Arrays.asList("Hello");
}
@TestFactory
Collection dynamicTestsFromCollection() {
return Arrays.asList(
dynamicTest("1st dynamic test", () -> assertTrue(isPalindrome("madam"))),
dynamicTest("2nd dynamic test", () -> assertEquals(4, calculator.multiply(2, 2)))
);
}
@TestFactory
Iterable dynamicTestsFromIterable() {
return Arrays.asList(
dynamicTest("3rd dynamic test", () -> assertTrue(isPalindrome("madam"))),
dynamicTest("4th dynamic test", () -> assertEquals(4, calculator.multiply(2, 2)))
);
}
@TestFactory
Iterator 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 dynamicTestsFromStream() {
return Stream.of("racecar", "radar", "mom", "dad")
.map(text -> dynamicTest(text, () -> assertTrue(isPalindrome(text))));
}
@TestFactory
Stream 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 generateRandomNumberOfTests() {
// Generates random positive integers between 0 and 100 until
// a number evenly divisible by 7 is encountered.
Iterator inputGenerator = new Iterator() {
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 displayNameGenerator = (input) -> "input:" + input;
// Executes tests based on the current input value.
ThrowingConsumer testExecutor = (input) -> assertTrue(input % 7 != 0);
// Returns a stream of dynamic tests.
return DynamicTest.stream(inputGenerator, displayNameGenerator, testExecutor);
}
@TestFactory
Stream 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 为何如此接近。。。
- 例子源码:https://github.com/jiangtj-lab/junit5-demo
本文作者: Mr.J
本文链接: https://www.dnocm.com/articles/cherry/junit-5-info/
版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!