Junit 5新特性全集

本文略长,但都是大白话,如果你能一口气看完,你赢了。 如果你来不及看这么长,那么建议你滑到文末,直接看黑体部分就知道大概了。

在5中的一个测试类的基本生命周期是这样的:

class Lifecycle {
 
	@BeforeAll
	static void initializeExternalResources() {
		System.out.println("Initializing external resources...");
	}
 
	@BeforeEach
	void initializeMockObjects() {
		System.out.println("Initializing mock objects...");
	}
 
	@Test
	void someTest() {
		System.out.println("Running some test...");
		assertTrue(true);
	}
 
	@Test
	void otherTest() {
		assumeTrue(true);
 
		System.out.println("Running another test...");
		assertNotEquals(1, 42, "Why wouldn't these be the same?");
	}
 
	@Test
	@Disabled
	void disabledTest() {
		System.exit(1);
	}
 
	@AfterEach
	void tearDown() {
		System.out.println("Tearing down...");
	}
 
	@AfterAll
	static void freeExternalResources() {
		System.out.println("Freeing external resources...");
	}
 
}

好,先感受下基本的样子。

本文主要内容目录:

基础篇

1、访问权限

2、测试类的生命周期

3、支持禁用测试

4、Assertions

5、Assumptions

6、嵌套测试(Nesting Tests)

7、给测试用例起名字

条件篇-Conditions

1、ContainerExecutionCondition

2、TestExecutionCondition

3、收集异常

4、JUnit是无状态的

参数注入或者叫参数测试篇

1、@ParameterizedTests细节

2、参数源

Value Source

Enum Source

Method Source

CSV Sources

自定义参数源

参数转换器

扩展模型篇

1、扩展点

基础篇

接下来开始说说Junit 5的基础吧。

1、访问权限

5的最明显的变化之一就是测试类和方法们不再public了。都是package可见,也不是private。这样的做法其实是一个明智的选择。

2、测试类的生命周期

@Test

Junit的最核心的就是@Test这个注解,把它放置在方法上来作为测试用例来运行。JUnit 5为每个测试方法创建一个新的测试实例,这个和Junit 4是一样的。

Before 和 After

在运行测试代码时你也许希望做一个初始化设置和结束后的清理操作。下面有4个方法注解可以帮助我们实现:

@BeforeAll: 只执行一次。在测试用例和标记了@BeforeEach的方法之前运行。

@BeforeEach: 在每个测试用例之前运行。

@AfterEach: 在每个测试用例之后运行。

@AfterAll: 执行一次。在测试用例和标记了@AfterEach的方法之后运行。

因为每个test都要创建一个实例,所以实例没机会去调用标记了@BeforeAll和@AfterAll的实例方法。所以这两个注解标记的方法只能是static的

3、支持禁用测试

星期五下午了,你也许一心想着回家。没问题,你只需要把@Disabled加到test方法上就可以了,而且还支持写几句话说说原因,然后运行。

@Test
@Disabled("Y U No Pass?!")
void failingTest() {
	assertTrue(false);
}

4、Assertions

如果说@Test, @Before...和@After...是一个测试套件的骨架,那么断言就是心脏。具体断言是啥就不说了。Junit 5在断言方面新增了如下支持。

'fail'

这严格来说不算断言,只是让测试直接失败,并返回失败信息。

@Test
void failTheTest() {
	fail("epicly");
}

'assertAll'

批量断言。可以把一个实例的字段批量断言,直到出现某个失败。

@Test
void assertAllProperties() {
	Address address = new Address("New City", "Some Street", "No");
 
	assertAll("address",
			() -> assertEquals("Neustadt", address.city),
			() -> assertEquals("Irgendeinestraße", address.street),
			() -> assertEquals("Nr", address.number)
	);
}

批量断言失败信息提示如下:

org.opentest4j.MultipleFailuresError: address (3 failures)
	expected: <Neustadt> but was: <New City>
	expected: <Irgendeinestraße> but was: <Some Street>
	expected: <Nr> but was: <No>

‘assertThrows’ 和 ‘excpectThrows’

当指定的方法没有抛出指定的异常,则失败。而且excpectThrows还会返回一个异常对象。你可以进一步对其进行断言,比如断言异常信息里是否含有特定的字符。

@Test
void assertExceptions() {
	assertThrows(Exception.class, this::throwing);
 
	Exception exception = expectThrows(Exception.class, this::throwing);
	assertEquals("Because I can!", exception.getMessage());
}

5、Assumptions

‘assumeTrue’, ‘assumeFalse’, and ‘assumingThat’

可以使用假设来中止不满足前提条件的测试,或者仅在条件成立的情况下才执行(部分)测试。 主要区别是中止测试被报告为禁用,而由于条件不成立而空的测试是纯绿色的。

@Test
void exitIfFalseIsTrue() {
	assumeTrue(false);
	System.exit(1);
}
 
@Test
void exitIfTrueIsFalse() {
	assumeFalse(this::truism);
	System.exit(1);
}
 
private boolean truism() {
	return true;
}
 
@Test
void exitIfNullEqualsString() {
	assumingThat(
			"null".equals(null),
			() -> System.exit(1)
	);
}

6、嵌套测试(Nesting Tests)

JUnit 5支持嵌套测试。只需要简单的使用@Nested把内部类标记了,里边所有的测试方法就也会被执行。

'@Nested'

import org.junit.gen5.api.BeforeEach;
import org.junit.gen5.api.Nested;
import org.junit.gen5.api.Test;
 
import static org.junit.gen5.api.Assertions.assertEquals;
import static org.junit.gen5.api.Assertions.assertTrue;
 
class Nest {
	
	int count = Integer.MIN_VALUE;
	
	@BeforeEach
	void setCountToZero() {
		count = 0;
	}
	
	@Test
	void countIsZero() {
		assertEquals(0, count);
	}
	
	@Nested
	class CountGreaterZero {
 
		@BeforeEach
		void increaseCount() {
			count++;
		}
 
		@Test
		void countIsGreaterZero() {
			assertTrue(count > 0);
		}
 
		@Nested
		class CountMuchGreaterZero {
 
			@BeforeEach
			void increaseCount() {
				count += Integer.MAX_VALUE / 2;
			}
 
			@Test
			void countIsLarge() {
				assertTrue(count > Integer.MAX_VALUE / 2);
			}
 
		}
 
	}
	
}

你也许在问这么做有什么好处呢?你可以使用这种做法可以让你的测试类更加的小而专注。

比如以下这个例子:

class TestingAStack {
 
    Stack<Object> stack;
    boolean isRun = false;
 
    @Test
    void isInstantiatedWithNew() {
        new Stack<Object>();
    }
 
    @Nested
    class WhenNew {
 
        @BeforeEach
        void init() {
            stack = new Stack<Object>();
        }
 
        // some tests on 'stack', which is empty
 
        @Nested
        class AfterPushing {
 
            String anElement = "an element";
 
            @BeforeEach
            void init() {
                stack.push(anElement);
            }
 
            // some tests on 'stack', which has one element...
 
        }
    }
}

7、给测试用例起名字

JUnit 5 新增了一个注解@DisplayName,这个注解可以给你的测试类和测试方法起个更易理解的名字。

像下面这样:

@DisplayName("A stack")
class TestingAStack {

    @Test
    @DisplayName("is instantiated with new Stack()")
    void isInstantiatedWithNew() { /*...*/ }

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

        @Test
        @DisplayName("is empty")
        void isEmpty() { /*...*/ }

        @Test
        @DisplayName("throws EmptyStackException when popped")
        void throwsExceptionWhenPopped() { /*...*/ }

        @Test
        @DisplayName("throws EmptyStackException when peeked")
        void throwsExceptionWhenPeeked() { /*...*/ }

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

            @Test
            @DisplayName("it is no longer empty")
            void isEmpty() { /*...*/ }

            @Test
            @DisplayName("returns the element when popped and is empty")
            void returnElementWhenPopped() { /*...*/ }

            @Test
            @DisplayName(
                    "returns the element when peeked but remains not empty")
            void returnElementWhenPeeked(){ /*...*/ }
        }
    }
}

这样就看起来非常的舒服了:

好,上面是基础新特性。接下来是条件篇

条件篇-Conditions

在Junit 5中增加了条件这个概念。增加了两个扩展点。你可以通过这两个扩展点,其实就是一些接口,基于这些接口做一些实现,然后通过@ExtendWith注解加入进去,就可以被Junit处理,在指定的条件满足的情况下就会调用这些实现。

其中两个接口比较有趣,就是ContainerExecutionCondition 和 TestExecutionCondition。

public interface ContainerExecutionCondition extends Extension {

	/**
	 * Evaluate this condition for the supplied ContainerExtensionContext.
	 *
	 * An enabled result indicates that the container should be executed;
	 * whereas, a disabled result indicates that the container should not
	 * be executed.
	 *
	 * @param context the current ContainerExtensionContext
	 */
	ConditionEvaluationResult evaluate(ContainerExtensionContext context);

}

public interface TestExecutionCondition extends Extension {

	/**
	 * Evaluate this condition for the supplied TestExtensionContext.
	 *
	 * An enabled result indicates that the test should be executed;
	 * whereas, a disabled result indicates that the test should not
	 * be executed.
	 *
	 * @param context the current TestExtensionContext
	 */
	ConditionEvaluationResult evaluate(TestExtensionContext context);

}

ContainerExecutionCondition定义了在某个container里的测试是否要被执行。通常情况下,测试用例的类就是container,每个具体的测试方法就是test。自然TestExecutionConditions就对应的是每个具体测试方法是否满足要被执行的条件接口。

notes:这里强调“通常情况下”是因为不同的测试引擎对于container和test有不同的解析。class和method只是一种最常见的情况。

junit 5中的condition实现都是基于这些接口来实现的,然后在evaluate方法中做一些必要的检查。

以下就是具体的Condition。

@Disabled

这是一个我们之前提到过的注解。这是一个最简单的condition。几乎没做什么检查,只要这个注解出现就表示该测试为禁用状态。

现在来模拟创建下这个@Disabled吧:

@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)@ExtendWith(@DisabledCondition.class)public @interface Disabled { }

看看具体的扩展实现:

public class DisabledCondition
		implements ContainerExecutionCondition, TestExecutionCondition {
 
	private static final ConditionEvaluationResult ENABLED =
			ConditionEvaluationResult.enabled("@Disabled is not present");
 
	@Override
	public ConditionEvaluationResult evaluate(
			ContainerExtensionContext context) {
		return evaluateIfAnnotated(context.getElement());
	}
 
	@Override
	public ConditionEvaluationResult evaluate(
			TestExtensionContext context) {
		return evaluateIfAnnotated(context.getElement());
	}
 
	private ConditionEvaluationResult evaluateIfAnnotated(
			AnnotatedElement element) {
		Optional<Disabled> disabled = AnnotationUtils
				.findAnnotation(element, Disabled.class);
 
		if (disabled.isPresent())
			return ConditionEvaluationResult
					.disabled(element + " is @Disabled");
 
		return ENABLED;
	}
 
}

基本上就是这个思路,只是和官方的有点不同就是:

1、官方注解不需要自带扩展名,因为它是默认注册的。

2、官方的当测试被跳过时可以添加reason信息。

@DisabledOnOs

有时候,我们只希望在特定的操作系统才运行指定的测试用例。

还是新建一个注解,如下:

@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)@ExtendWith(OsCondition.class)public @interface DisabledOnOs {
 
	OS[] value() default {};
 
}

public class OsCondition 
		implements ContainerExecutionCondition, TestExecutionCondition {
 
	// both `evaluate` methods forward to `evaluateIfAnnotated` as above
 
	private ConditionEvaluationResult evaluateIfAnnotated(
			AnnotatedElement element) {
		Optional<DisabledOnOs> disabled = AnnotationUtils
				.findAnnotation(element, DisabledOnOs.class);
 
		if (disabled.isPresent())
			return disabledIfOn(disabled.get().value());
 
		return ENABLED;
	}
 
	private ConditionEvaluationResult disabledIfOn(OS[] disabledOnOs) {
		OS os = OS.determine();
		if (Arrays.asList(disabledOnOs).contains(os))
			return ConditionEvaluationResult
					.disabled("Test is disabled on " + os + ".");
		else
			return ConditionEvaluationResult
					.enabled("Test is not disabled on " + os + ".");
	}
 
}

然后就可以这样使用了:

@Test
@DisabledOnOs(OS.WINDOWS)
void doesNotRunOnWindows() {
	assertTrue(false);
}

然后我们可以做一个组合,让我们的注解看起来更加的优雅和简洁,像这样:

@TestExceptOnOs(OS.WINDOWS)
void doesNotRunOnWindowsEither() {
	assertTrue(false);
}

@TestExceptOnOs实现:

@Retention(RetentionPolicy.RUNTIME)
@Test
@DisabledOnOs(/* somehow get the `value` below */)
public @interface TestExceptOnOs {
 
	OS[] value() default {};
 
}

@DisabledIfTestFails

现在我们可以做一件更加有趣的事情,就是假设现在有一个集成测试,如果有其中一个测试fail了(含有一个指定的异常),那么其他的相关测试也fail了。为了节省时间,我们希望disable它们。

很明显,我们必须以某种方式收集测试执行过程中抛出的异常。 这必须绑定到测试类的生命周期,所以我们不要禁用测试用例,因为某些异常是在完全不同的测试类中存在的。 所以,我们就需要一个condition实现来检查是否抛出了一个特定的异常,如果是这样的话就禁用这个测试。

收集异常

你可以去看看扩展点列表就会发现有个Exception Handling,通过这个我们就可以实现异常收集了:

/**
 * ExceptionHandlerExtensionPoint defines the API for Extension Extensions
 * that wish to react to thrown exceptions in tests.
 *
 * [...]
 */
public interface ExceptionHandlerExtensionPoint extends ExtensionPoint {

	/**
	 * React to a throwable which has been thrown by a test method.
	 *
	 * Implementors have to decide if they
	 * 
	 * - Rethrow the incoming throwable
	 * - Throw a newly constructed Exception or Throwable
	 * - Swallow the incoming throwable
	 *
	 * [...]
	 */
	void handleException(TestExtensionContext context, Throwable throwable)
			throws Throwable;
}

看完上面的接口你发现,我们现在就只需要实现handleException就是了,然后把测试过程中的异常收集起来然后重新抛出就是了。

JUnit是无状态的

这里要明白一个事情,就是Junit引擎默认是无状态的,引擎对扩展实例的初始化时间和实例的生存时间未做任何保证。所以要想保存任何你需要的状态,你可以通过JUnit内部的一个存储库来存取,这个存取库是专门用来保存一些状态的,姑且叫做“store”吧。

好,现在我们就是用这个store。里边保存了一些我们想要记录的东西。我们可以通过“扩展上下文(ExtensionContext)”来获取到它。这个ExtensionContext会被扩展方法处理。只是这里需要注意一点,就是每个上下文都有自己的store,所以我们得决定到底使用哪个上下文。

每个测试方法都有一个上下文,叫TestExtensionContext,每个测试类也有一个上下文ContainerExtensionContext。这里我们希望记录在一个类中所有的测试用例在执行过程中所抛出的异常,其他测试类的异常不记录。所以ContainerExtensionContext的store就是我们需要的

所以,我们接下来就拿到container的context,然后使用它的store来存储抛出的异常列表。

private static final Namespace NAMESPACE = Namespace
		.of("org", "codefx", "CollectExceptions");
private static final String THROWN_EXCEPTIONS_KEY = "THROWN_EXCEPTIONS_KEY";
 
@SuppressWarnings("unchecked")
private static Set<Exception> getThrown(ExtensionContext context) {
	ExtensionContext containerContext = getAncestorContainerContext(context)
			.orElseThrow(IllegalStateException::new);
	return (Set<Exception>) containerContext
			.getStore(NAMESPACE)
			.getOrComputeIfAbsent(
					THROWN_EXCEPTIONS_KEY,
					ignoredKey -> new HashSet<>());
}
 
private static Optional<ExtensionContext> getAncestorContainerContext(
		ExtensionContext context) {
	Optional<ExtensionContext> containerContext = Optional.of(context);
	while (containerContext.isPresent()
			&& !(containerContext.get() instanceof ContainerExtensionContext))
		containerContext = containerContext.get().getParent();
	return containerContext;
}

现在添加一个异常就非常简单了:

@Override
public void handleException(TestExtensionContext context, Throwable throwable)
		throws Throwable {
	if (throwable instanceof Exception)
		getThrown(context).add((Exception) throwable);
	throw throwable;
}

这真的是一个非常有趣的扩展。这个甚至都可以用来分析。异常已经被收集好了,现在我们需要一个public方法来返回这个异常列表:

public static Stream<Exception> getThrownExceptions(
		ExtensionContext context) {
	return getThrown(context).stream();
}

有了这个方法,其他的扩展就可以检出到迄今为止所抛出的异常列表了。

Disable

接下来的事情就是和之前差不多了:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(DisabledIfTestFailedCondition.class)
public @interface DisabledIfTestFailedWith {
 
	Class<? extends Exception>[] value() default {};
 
}

注意,现在仅允许此注解被用在方法上(可以用在类上,先就只在方法上吧)。因此我们只需要实现接口TestExecutionCondition即可。检查注解存在后就拿到用户提供的异常类作为参数,去调用disableIfExceptionWasThrown方法:

private ConditionEvaluationResult disableIfExceptionWasThrown(
		TestExtensionContext context,
		Class<? extends Exception>[] exceptions) {
	return Arrays.stream(exceptions)
			.filter(ex -> wasThrown(context, ex))
			.findAny()
			.map(thrown -> ConditionEvaluationResult.disabled(
					thrown.getSimpleName() + " was thrown."))
			.orElseGet(() -> ConditionEvaluationResult.enabled(""));
}
 
private static boolean wasThrown(
		TestExtensionContext context, Class<? extends Exception> exception) {
	return CollectExceptionExtension.getThrownExceptions(context)
			.map(Object::getClass)
			.anyMatch(exception::isAssignableFrom);
}

把上面的揉到一块

到此为止我们完成了使用注解来disable测试了,当某个特定类型的异常被抛出时,我们就可以禁用测试了:

@CollectExceptions
class DisabledIfFailsTest {
 
	private static boolean failedFirst = false;
 
	@Test
	void throwException() {
		System.out.println("I failed!");
		failedFirst = true;
		throw new RuntimeException();
	}
 
	@Test
	@DisabledIfTestFailedWith(RuntimeException.class)
	void disableIfOtherFailedFirst() {
		System.out.println("Nobody failed yet! (Right?)");
		assertFalse(failedFirst);
	}
 
}

Condition阶段性总结

现在我们已经知道了在JUnit 5中如何实现condition了:

  1. 创建一个注解,然后使用@ExtendWith来设置你的condition实现类。
  2. 实现 ContainerExecutionCondition, TestExecutionCondition, 或两个都实现。
  3. 检查测试类和测试方法上是否添加了刚刚新建的注解。
  4. 执行你自定义的检查逻辑然后返回result。

另外你还学到了注解之间如何组合以及如何使用store来存储信息以及通过自定义注解让使用扩展变得更加的优雅等。

好,接下来,我们来研究参数注入。

参数注入或者叫参数测试篇

开始吧。

引入依赖

要想进行参数测试,你需要引入以下依赖:

Group ID: org.junit.jupiter

Artifact ID: junit-jupiter-params

Version: 5.0.0-M4(具体取最新版)

开始

添加一个@ParameterizedTest来代替@Test,然后方法上有参数,暂且如下:

@ParameterizedTest
// 这里好像缺点啥 - word的值从哪里来呢?
void parameterizedTest(String word) {
    assertNotNull(word);
}

这看起来缺点啥。在这种没有给参数赋值的情况下方法将不执行(执行0次)。

为了让它执行,我们就需要提供参数值才行,最简单的方法就是使用@ValueSource:

@ParameterizedTest
@ValueSource(strings = { "Hello", "JUnit" })
void withValueSource(String word) {
    assertNotNull(word);
}

事实上上面的方法执行了两次:一次是Hello,一次是JUnit。在IntelliJ看起来是这样:

好,先简单的感受下参数测试的神奇。

如果在真实的项目中使用,你还需要知道接下来的一些事情。比如@ParamterizedTest的更多细节(比如如何起名字等),还有其他的参数源(argument source)(甚至包括你自己如何自定义一个自己的参数源)以及目前为止最神秘的功能:参数转换器(argument converter)。接下来我们就详细介绍这些吧。

@ParameterizedTests细节

Test Name

你也许发现了,上面的那个idea截图中的结构。参数化测试方法被作为一个集合来展示,有父节点和子节点,每次调用都是一个子节点,这些子节点的名字默认的格式是“[{index}] {arguments}”,就像你现在看到的那样,我们可以通过在@ParameterizedTest中添加name属性来设置子节点的name格式,如下:

@ParameterizedTest(name = "run #{index} with [{arguments}]")
@ValueSource(strings = { "Hello", "JUnit" })
void withValueSource(String word) { }

只要trim后不是空的,任何字符串都可以被用作测试的名字,并支持以下参数:

  • {index}: 索引,从1开始。
  • {arguments}: 把每次调用的方法参数值按照 {0}, {1}, … {n} 来替换,目前我们只有一个参数。
  • {i}: 被第i个参数在当前调用中的参数取代(下面是详细例子)。

现在我们换个source,使用@CsvSource,先不讲这个source的细节,只是尝试下使用它来构建一个厉害的测试名称,特别是和@DisplayName一起:

@DisplayName("Roman numeral")
@ParameterizedTest(name = "\"{0}\" should be {1}")
@CsvSource({ "I, 1", "II, 2", "V, 5"})
void withNiceName(String word, int number) {    }

Non-Parameterized Parameters

非参数化参数

@ParameterizedTest
@ValueSource(strings = { "Hello", "JUnit" })
void withOtherParams(String word, TestInfo info, TestReporter reporter) {
    reporter.publishEntry(info.getDisplayName(), "Word: " + word);
}

和上面一样,这个方法被调用两次,两次参数解析器都必须提供TestInfo和TestReporter的实例。

Meta Annotations

你也可以通过封装@ParameterizedTest来创建一些自定义的扩展注解,如下:

@Params
void testMetaAnnotation(String s) { }
 
@Retention(RetentionPolicy.RUNTIME)
@ParameterizedTest(name = "Elaborate name listing all {arguments}")
@ValueSource(strings = { "Hello", "JUnit" })
@interface Params { }

参数源(Argument Sources)

1、Value Source

前面你已经知道了一种数据源就是Value Source。这个使用起来比较简单,你只需要传入一个以下类型的数组即可:

String[] strings()

int[] ints()

long[] longs()

double[] doubles()

前面有个例子是传的strings,下面这个例子传的是longs。

@ParameterizedTest
@ValueSource(longs = { 42, 63 })
void withValueSource(long number) { }

但是这个source有两个主要的不足:

1、由于类型的限制,不能传入任意的对象(尽管后面我们会介绍参数转换器:argument converter)

2、只适用于单个参数的测试方法,如果是多个参数就无法驾驭了。

好,接下来看看其他的测试源吧。

2、Enum Source

传入枚举,则会对枚举的每个值执行一次,同时可以通过names来控制要执行哪些枚举值:

@ParameterizedTest
@EnumSource(TimeUnit.class)
void withAllEnumValues(TimeUnit unit) {
    // 针对每个unit都执行一次
}
 
@ParameterizedTest
@EnumSource(
    value = TimeUnit.class,
    names = {"NANOSECONDS", "MICROSECONDS"})
void withSomeEnumValues(TimeUnit unit) {
    // 对TimeUnit.NANOSECONDS执行一次
    // 并且对TimeUnit.MICROSECONDS执行一次
}

枚举源也只能单参数。

好,继续。

4、Method Source

@ValueSource 和 @EnumSource 都只能单参数,而且类型有所限制。@MethodSource就是支持多个参数,而且可以自定义类型,只需要创建个私有方法,然后返回一个stream流就可以了:

@ParameterizedTest
@MethodSource(names = "createWordsWithLength")
void withMethodSource(String word, int length) { }
 
private static Stream createWordsWithLength() {
    return Stream.of(
            ObjectArrayArguments.create("Hello", 5),
            ObjectArrayArguments.create("JUnit 5", 7));
}

ObjectArrayArguments.create(Object… args) 创建一个对象数组参数实例,然后通过stream返回。在这里withMethodSource方法执行了两次:一次是Hello和5传入后执行,一次是JUnit 5和7传入后执行。

另外就是@MethodSource中指定的方法必须是一个私有并且静态的(private static ...)。并且返回值必须是collection的一个类型,一个可以被stream、iterable、iterator的或者是个数组。

如果source只是针对一个参数的方法,那么可以直接写成下面这样,就不用像上面那样包装了:

@ParameterizedTest
@MethodSource(names = "createWords")
void withMethodSource(String word) { }
 
private static Stream createWords() {
    return Stream.of("Hello", "Junit");
}

As I said, @MethodSource is the most general source Jupiter has to offer. But it incurs the overhead of declaring a method and putting together the arguments, which is a little much for simpler cases. These can be best served with the two CSV sources.

@MethodSource是Jupiter中一个通用的数据源。但有一些不足之处,比如你得声明个方法(姑且算是不足吧)。

接下来我们就介绍另外一个测试数据源CSV源。

5、CSV Sources

现在不用定义数据源方法了,直接在@CsvSource里把数据准备好就是了,每次调用用到的参数通过逗号分隔:

@ParameterizedTest
@CsvSource({ "Hello, 5", "JUnit 5, 7", "'Hello, JUnit 5!', 15" })
void withCsvSource(String word, int length) { }

在这个例子中,source中有三组参数,会触发三次测试调用。值得注意的是,第三组参数里有个单引号,这是因为,第三组参数的值里包含了逗号,为了避免被分隔,所以加了单引号。

所有的参数都是通过字符串来表示,这就带来一个问题,就是这些串怎么被转换给适当的类型,这个稍后会专门说到。现在我们先来另外一个问题,就是当数据量很大的时候,这时候就适合把测试数据存储到一个单独的文件中,而不是代码中,比如excel文件:

@ParameterizedTest
@CsvFileSource(resources = "word-lengths.csv")
void withCsvSource(String word, int length) { }

这里注意的是,resources可以传递多个文件名,然后JUnit会挨个处理。另外就是@CsvFileSource支持指定文件encoding,行分隔符以及分隔符。

6、自定义参数源

如果上面的这些内置源都无法满足你的需求场景的话,你完全可以自定义自己的源。话不多说,实现这个接口就是了:

public interface ArgumentsProvider {
 
    Stream<? extends Arguments> provideArguments(
        ContainerExtensionContext context) throws Exception;
 
}

然后通过 @ArgumentsSource(MySource.class)来使用你自己的源,或者再自定义一个注解包装下。另外你也可以通过那个context获取到更多的信息。

好,接下来我们就来介绍“参数转换器(Argument Converters)”

7、参数转换器(Argument Converters)

上面列的参数源的类型都比较局限,只是string、枚举、等其他一些基本类型。这显然不能满足包罗万象的测试用例,在很多场景下,需要一些更加丰富的类型。参数转换器就是干这个的:

@ParameterizedTest
@CsvSource({ "(0/0), 0", "(0/1), 1", "(1/1), 1.414" })
void convertPointNorm(@ConvertPoint Point point, double norm) { }

让我们看看怎么运行的。。。

首先,不管参数中提供的是什么,转换器都负责把它转换成另外一个表示方式。

默认 Converter

Jupiter 提供了一个默认的转换器,这个转换器会在你没指定转换器的时候被使用。如果数据源的参数和方法上的参数类型匹配,那么默认转换器啥也不干,否则就会把String转换为一个具体的数字类型:

  1. string的长度为1,那么会转换成char或Character。
  2. 所有的其他的基本类型以及他们的包装引用类型。会解析成valueOf方法返回值)。
  3. 任何枚举。解析成Enum::valueOf。
  4. 一些像Instant,LocalDateTime等的时间类型,OffsetDateTime等,ZonedDateTime,Year和YearMonth,各自的解析方法。

下面是一个简单的例子,展示其中的一些实际操作:

@ParameterizedTest
@CsvSource({"true, 3.14159265359, JUNE, 2017, 2017-06-21T22:00:00"})
void testDefaultConverters(
        boolean b, double d, Summer s, Year y, LocalDateTime dt) { }
 
enum Summer {
    JUNE, JULY, AUGUST, SEPTEMBER;
}

通过上面的例子我们可以看到了上面那些支持的类型的转换,相信以后会支持更多的类型。但再怎么支持,也无法支持到你自己定义的那么的类型。自己定义的类型如何转换呢?接下来的自定义转换器就是干这个的。

自定义转换器

自定义转换器允许我们把我们的测试数据源中的类型转换成我们测试方法参数上定义的任意类型。创建一个自定义转换器只需要实现下面这个接口就是了:

public interface ArgumentConverter {
 
    Object convert(
            Object input, ParameterContext context)
            throws ArgumentConversionException;
 
}

可以看到这个接口的输入和输出都是无类型的。你还可以通过ParameterContext获取到更多有关参数的信息,比如参数的类型,以及测试方法所在的实例等等。

对于已经有类似“(1/0)”字符串的静态工厂方法的Point类,convert方法就像这样简单:

@Override
public Object convert(
        Object input, ParameterContext parameterContext)
        throws ArgumentConversionException {
    if (input instanceof Point)
        return input;
    if (input instanceof String)
        try {
            return Point.from((String) input);
        } catch (NumberFormatException ex) {
            String message = input
                + " is no correct string representation of a point.";
            throw new ArgumentConversionException(message, ex);
        }
    throw new ArgumentConversionException(input + " is no valid point");
}

首先去检查是不是Point实例,如果是直接返回;如果是string类型,那么就把string转换成Point类型返回。

现在我们就可以通过@ConvertWith来使用这个转换器了:

@ParameterizedTest
@ValueSource(strings = { "(0/0)", "(0/1)","(1/1)" })
void convertPoint(@ConvertWith(PointConverter.class) Point point) { }

或者你可以通过自己建个注解包装一下:

@ParameterizedTest
@ValueSource(strings = { "(0/0)", "(0/1)","(1/1)" })
void convertPoint(@ConvertPoint Point point) { }
 
@Target({ ElementType.ANNOTATION_TYPE, ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
@ConvertWith(PointConverter.class)
@interface ConvertPoint { }

你可以让你的转换器和类似 @ValueSource 或 @CsvSource这样的源配合使用, 这样就可以让这些源的数据转换成你想要的类型。

扩展模型篇

JUnit 5中的扩展模型允许libraries和frameworks去添加他们自己的增强到JUnit。

扩展点(Extension Points)

JUnit 5 扩展可以被声明到测试生命周期的某个“环节(junctures)”。

当JUnit 5引擎处理一个test的时候,它会一步步经过这些环节(junctures),并调用每个注册上去的“扩展”,这些扩展你可以理解为外挂。

以下就是扩展点:

Test Instance Post Processing

BeforeAll Callback

Conditional Test Execution

BeforeEach Callback

Parameter Resolution

Exception Handling

AfterEach Callback

AfterAll Callback

(别急,如果暂时对上面的不清楚,没关系,一会会讲到。)

每个扩展点对应一个接口。 扩展点的方法需要在测试生命周期的特定时刻捕获上下文(context)的参数,例如 测试实例和方法,测试的名称,参数,注解等等。一个扩展可以实现多个接口,并将被引擎用各自的参数调用。里边可以实现自己想要的功能。

需要注意的是:这一切是无状态的,如果需要保存什么,请保存到JUnit的store中,前面我也说过。

在创建了扩展后,接下来要做的事情就是告诉JUnit这一切。你只需要通过@ExtendWith(MyExtension.class)这样的方式把扩展添加到测试类或方法上就可以了。

自定义注解

JUnit 5 API由注解驱动的,引擎在检查它们的存在时做了一些额外的工作:它不仅在类,方法和参数上查找注解,还在其他注解上查找注解。

总之就是你可以利用对注解的组合和再次封装,实现出来一些看起来更简单的功能支持,核心就是:JUnit 5可以自动的去查找它能认出的那些注解。话不多说上例子:

/**
 * We define a custom annotation that:
 * - stands in for '@Test' so that the method gets executed
 * - has the tag "integration" so we can filter by that,
 *   e.g. when running tests from the command line
 */
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Test
@Tag("integration")
public @interface IntegrationTest { }

于是我们就可以这样使用了:

@IntegrationTest
void runsWithCustomAnnotation() {
    // this gets executed
    // even though `@IntegrationTest` is not defined by JUnit
}

也可以针对我们的扩展来封装注解:

@Target({ ElementType.TYPE, ElementType.METHOD, ElementType.ANNOTATION_TYPE })
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(ExternalDatabaseExtension.class)
public @interface Database { }

现在我们就可以使用 @Database来代替@ExtendWith(ExternalDatabaseExtension.class)了。

一个例子

现在假设我们想要benchmark 测试用例们的运行时长。首先,我们创建一个注解:

@Target({ ElementType.TYPE, ElementType.METHOD, ElementType.ANNOTATION_TYPE })
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(BenchmarkCondition.class)
public @interface Benchmark { }

下面是BenchmarkCondition的具体实现,我们实现了四个扩展点:

public class BenchmarkCondition implements
		BeforeAllExtensionPoint, BeforeEachExtensionPoint,
		AfterEachExtensionPoint, AfterAllExtensionPoint {
	private static final Namespace NAMESPACE =
			Namespace.of("BenchmarkCondition");
	@Override
	public void beforeAll(ContainerExtensionContext context) {
		if (!shouldBeBenchmarked(context))
			return;
		writeCurrentTime(context, LaunchTimeKey.CLASS);
	}
	@Override
	public void beforeEach(TestExtensionContext context) {
		if (!shouldBeBenchmarked(context))
			return;
		writeCurrentTime(context, LaunchTimeKey.TEST);
	}
	@Override
	public void afterEach(TestExtensionContext context) {
		if (!shouldBeBenchmarked(context))
			return;
		long launchTime = loadLaunchTime(context, LaunchTimeKey.TEST);
		long runtime = currentTimeMillis() - launchTime;
		print("Test", context.getDisplayName(), runtime);
	}
	@Override
	public void afterAll(ContainerExtensionContext context) {
		if (!shouldBeBenchmarked(context))
			return;
		long launchTime = loadLaunchTime(context, LaunchTimeKey.CLASS);
		long runtime = currentTimeMillis() - launchTime;
		print("Test container", context.getDisplayName(), runtime);
	}
	private static boolean shouldBeBenchmarked(ExtensionContext context) {
		return context.getElement().isAnnotationPresent(Benchmark.class);
	}
	private static void writeCurrentTime(
			ExtensionContext context, LaunchTimeKey key) {
		context.getStore(NAMESPACE).put(key, currentTimeMillis());
	}
	private static long loadLaunchTime(
			ExtensionContext context, LaunchTimeKey key) {
		return (Long) context.getStore(NAMESPACE).remove(key);
	}
	private static void print(
			String unit, String displayName, long runtime) {
		System.out.printf("%s '%s' took %d ms.%n", unit, displayName, runtime);
	}
	private enum LaunchTimeKey {CLASS, TEST}
}

扩展模型阶段总结

上面为你介绍了JUnit 5的扩展模型,其中我们介绍很多的扩展点,这些扩展点可以帮助我们在测试的生命周期的每个环节中额外添加我们想要的逻辑,从而实现一些我们想要的能力。

大总结

本文有点长,但却包含了JUnit5主要的新特性。让我们再次回顾下目录:

基础篇

1、访问权限

2、测试类的生命周期

3、支持禁用测试

4、Assertions

5、Assumptions

6、嵌套测试(Nesting Tests)

7、给测试用例起名字

条件篇-Conditions

1、ContainerExecutionCondition

2、TestExecutionCondition

3、收集异常

4、JUnit是无状态的

参数注入或者叫参数测试篇

1、@ParameterizedTests细节

2、参数源

Value Source

Enum Source

Method Source

CSV Sources

自定义参数源

参数转换器

扩展模型篇

1、扩展点

通过学习本文,你可以知道JUnit5的访问权限变了,支持禁用测试了,支持批量断言了,还可以断言异常了,还可以搞嵌套测试让你的测试更加小而集中;支持通过添加条件来决定测试的触发场景;另外Junit是无状态的,如果你要保存信息,需要保存到store里边;然后我们向你介绍了最最重要的一个内容就是测试源(或者叫参数注入),支持了很多的参数源从基本的Value Source,然后到你可以创建方法来设置源,还支持文件比如excel等,还支持自定义转换器来适应你应用更丰富的类型。最后我们向你介绍了JUnit 5中增加的扩展点,通过扩展点,你可以把你自己的逻辑挂载到测试生命周期的某个环节上,从而实现你想要的能力。

你赢了!

原文发布于微信公众号 - ImportSource(importsource)

原文发表时间:2018-02-12

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏源码之家

word如何自动分割成多个文档

3965
来自专栏偏前端工程师的驿站

Java魔法堂:JUnit4使用详解

目录                                                                              ...

1825
来自专栏iOS122-移动混合开发研究院

【自问自答】关于 Swift 的几个疑问

感觉自己给自己释疑,也是一个极为有趣的过程。这次,我还新增了“猜想”一栏,来尝试回答一些暂时没有足够资料支撑的问题。 Swift 版本是:4.0.3。不同版本的...

3166
来自专栏Android 研究

OKHttp源码解析(五)--OKIO简介及FileSystem

okio是由square公司开发的,它补充了java.io和java.nio的不足,以便能够更加方便,快速的访问、存储和处理你的数据。OKHttp底层也是用该库...

1862
来自专栏JMCui

Netty 系列六(编解码器).

    网络传输的单位是字节,如何将应用程序的数据转换为字节,以及将字节转换为应用程序的数据,就要说到到我们该篇介绍的编码器和解码器。

1001
来自专栏蓝天

boost::bind和boost::function使用示例

C++11已支持bind和function,之前的不支持,但可以借助boost达到同样目的。看如下两段代码:

842
来自专栏逍遥剑客的游戏开发

MPQ文件系统优化(续)

1705
来自专栏wannshan(javaer,RPC)

dubbo通信消息解析过程分析(1)

由于rpc底层涉及网络编程接口,线程模型,网络数据结构,服务协议,细到字节的处理。牵涉内容较多,今天就先从一个点说起。 说说,dubbo通过netty框架做传...

4926
来自专栏JavaEE

做Java开发,你需要了解这些前言

在开发中,我们写的代码肯定是越少越好,代码层次越清晰越好。那么下面就介绍一些可以减少代码量、可以让结构更清晰的好东西。本文涉及vo、dto的使用、全局异常处理、...

1423
来自专栏菩提树下的杨过

rpc框架之 thrift 学习 2 - 基本概念

thrift的基本构架: ? 上图源自:http://jnb.ociweb.com/jnb/jnbJun2009.html 底层Underlying I/O以上...

2497

扫码关注云+社区

领取腾讯云代金券