Skip to content

JUnit扩展机制

本文主要介绍JUnit中的扩展机制。

1. 概述

JUnit中的扩展机制允许通过实现特定的接口,“钩入”(Hook)到测试执行生命周期的各个阶段,从而在不修改测试代码的前提下,增强或改变测试的行为。

在JUnit Jupiter中,扩展机制是由一个统一的API定义的:Extension接口,注意,Extension接口只是一个标记接口,并没有实际作用:

java
public interface Extension {
}

要想实现扩展功能,就需要实现Extension接口的子类,扩展功能大致分为以下几类:

扩展点分类核心接口示例触发时机 / 作用
生命周期钩子BeforeEachCallback, AfterAllCallback在测试方法或类执行前后运行代码。
条件执行ExecutionCondition根据环境变量、OS 等条件动态决定是否跳过测试。
参数解析ParameterResolver为测试方法的参数自动注入数据(如 Mock 对象、随机数)。
异常处理TestExecutionExceptionHandler捕获测试抛出的异常,决定是吞掉它还是重新抛出。
结果处理TestWatcher对测试结果进行处理
拦截器InvocationInterceptor最强大的接口,可以包裹并控制测试执行(实现重试、超时等)。

下面就详细介绍这些扩展点。

2. 生命周期钩子

生命周期钩子可以分为两类:测试类的钩子和测试方法的钩子。

2.1 类的生命周期钩子

类的生命周期钩子有三个:

下面自定义了一个扩展组件,实现了以上接口:

java
public class MyClassExtension implements TestInstancePreConstructCallback,
        TestInstancePostProcessor, TestInstancePreDestroyCallback {
    @Override
    public void preConstructTestInstance(TestInstanceFactoryContext factoryContext, ExtensionContext context) throws Exception {
        System.out.println("preConstructTestInstance");
    }

    @Override
    public void postProcessTestInstance(Object testInstance, ExtensionContext context) throws Exception {
        // 可以在这里进行依赖注入
        System.out.println("postProcessTestInstance");
    }

    @Override
    public void preDestroyTestInstance(ExtensionContext context) throws Exception {
        System.out.println("preDestroyTestInstance");
    }
}

在测试类中,可以使用@ExtendWith注册扩展组件,关于更多的注册方法,可查看第8节-注册扩展组件。

java
@ExtendWith(MyClassExtension.class)
public class TestDemo {

    TestDemo(){
        System.out.println("构造方法");
    }

    @Test
    void test01(){
        System.out.println("test01");
    }

    @Test
    void test02(){
        System.out.println("test02");
    }

}

运行结果如下:

txt
preConstructTestInstance
构造方法
postProcessTestInstance
test01
preDestroyTestInstance
preConstructTestInstance
构造方法
postProcessTestInstance
test02
preDestroyTestInstance

WARNING

注意,如果在IDEA中单独运行test01()方法,会报错如下:

Exception in thread "main" java.lang.NoSuchMethodError: 'java.lang.String org.junit.platform.engine.discovery.MethodSelector.getMethodParameterTypes()' at com.intellij.junit5.JUnit5TestRunnerUtil.loadMethodByReflection(JUnit5TestRunnerUtil.java:127)

可以看到IDEA使用的是JUnit5TestRunnerUtil,这是由于IDEA版本和JUnit版本不兼容导致的,可以将JUnit版本切换回5后,运行单个测试方法成功。

2.2 方法的生命周期钩子

方法的生命周期钩子,是指在生命周期方法、测试方法前后执行的钩子函数,主要有以下接口:

下面自定义扩展组件,实现上述接口:

java
public class MyMethodExtension implements BeforeAllCallback, AfterAllCallback,
        BeforeEachCallback, AfterEachCallback, BeforeTestExecutionCallback, AfterTestExecutionCallback{

    @Override
    public void beforeAll(ExtensionContext context) throws Exception {
        System.out.println("beforeAll callback");
    }

    @Override
    public void afterAll(ExtensionContext context) throws Exception {
        System.out.println("afterAll callback");
    }

    @Override
    public void beforeEach(ExtensionContext context) throws Exception {
        System.out.println("beforeEach callback");
    }

    @Override
    public void afterEach(ExtensionContext context) throws Exception {
        System.out.println("afterEach callback");
    }

    @Override
    public void beforeTestExecution(ExtensionContext context) throws Exception {
        System.out.println("beforeTestExecution callback");
    }

    @Override
    public void afterTestExecution(ExtensionContext context) throws Exception {
        System.out.println("afterTestExecution callback");
    }

}

在同一个测试类上注册该扩展组件:

java
@ExtendWith(MyClassExtension.class)
@ExtendWith(MyMethodExtension.class)
public class TestDemo {
}

执行结果如下:

java
beforeAll callback  
preConstructTestInstance
构造方法
postProcessTestInstance
beforeEach callback
beforeTestExecution callback
test01
afterTestExecution callback
afterEach callback
preDestroyTestInstance
preConstructTestInstance
构造方法
postProcessTestInstance
beforeEach callback
beforeTestExecution callback
test02
afterTestExecution callback
afterEach callback
preDestroyTestInstance
afterAll callback  

3. 条件执行

在JUnit中,ExecutionCondition接口决定了是否跳过测试,这是@Disabled的底层原理,接口定义如下:

java
public interface ExecutionCondition extends Extension {
	ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context);
}

如果该扩展组件注册在测试类上,那么运行流程如下:

  • JUnit 引擎会先对类调用一次 evaluateExecutionCondition()方法。此时 context.getTestMethod() 是空的。

    如果在这里返回了 disabled,那么该类下的所有方法都不会执行。

  • 如果类级别返回了 enabled,引擎会再对每一个方法分别调用该方法。此时 context.getTestMethod() 才有值。根据evaluateExecutionCondition()方法返回值,决定是否执行该测试方法。

如果注册了多个ExecutionCondition,那么只要有一个返回disabled,那么该测试类/方法就不会执行,因此,其他的ExecutionCondition扩展组件有可能不会执行,这与Java中的短路原则类似(AND、OR)。

例如,下面定义了一个根据测试方法名称来决定是否执行测试的组件:

java
public class MyExecutionCondition implements ExecutionCondition {
    @Override
    public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {
        // 1. 尝试获取当前测试方法(如果是类级别调用,可能为空)
        return context.getTestMethod().map(method -> {
            String methodName = method.getName();

            // 2. 逻辑判断:如果方法名包含 "Ignore",则跳过
            if (methodName.contains("Ignore")) {
                return ConditionEvaluationResult.disabled("方法名包含 Ignore,已跳过执行");
            }

            return ConditionEvaluationResult.enabled("方法名校验通过");
        }).orElse(ConditionEvaluationResult.enabled("类级别,默认启用"));
    }
}

然后在测试类上注册该组件:

java
@ExtendWith(MyExecutionCondition.class)
public class ExecutionConditionDemo {

    @Test
    void test1(){
        System.out.println("test1");
    }

    @Test
    void testIgnore(){
        System.out.println("testIgnore");
    }
}

测试结果如下:

txt
test1

方法名包含 Ignore,已跳过执行

TIP

除了@Disabled注解,在JUnit中,还定义了很多基于条件执行测试的注解,参考:https://docs.junit.org/6.0.3/writing-tests/conditional-test-execution.html

4. 参数解析

ParameterResolver可以在运行时用来解析测试类构造方法、生命周期方法和测试方法的参数,例如JUnit内置的TestInfoParameterResolverParameterResolver接口定义如下:

java
public interface ParameterResolver extends TestInstantiationAwareExtension {
    boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException;

    Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException;
}
  • supportsParameter:判断是否支持解析某个参数,可以通过参数名称、类型、注解或者三者的组合来判断;

    如果只想根据参数类型来判断是否能解析,可以实现TypeBasedParameterResolver类。

  • resolveParameter:解析参数值;

TIP

由于在JDK 9之前,使用java.lang.reflect.Parameter API解析嵌套类(例如@Nested测试类)构造方法参数注解会失败,所以JUnit在ParameterContext中提供了正确的API,用来获取注解:

  • boolean isAnnotated(Class<? extends Annotation> annotationType)
  • Optional<A> findAnnotation(Class<A> annotationType)
  • List<A> findRepeatableAnnotations(Class<A> annotationType)

例如,下面自定义一个参数解析器,用来解析标注了@RandomString注解的参数。

首先定义注解,注意注解保留策略:

java
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.PARAMETER})
public @interface RandomString {
    // 默认长度为10
    int length() default 10;
}

然后定义参数解析器:

java
public class RandomStringParameterResolver implements ParameterResolver {
    @Override
    public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
        return parameterContext.isAnnotated(RandomString.class);
    }

    @Override
    public @Nullable Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {

        Optional<RandomString> annotation = parameterContext.findAnnotation(RandomString.class);
        if (annotation.isPresent()) {
            RandomString randomString = annotation.get();
            return generate(randomString.length());
        }

        return null;
    }

    /**
     * 生成指定长度的随机字符串
     *
     * @param length 字符串长度
     * @return 随机字符串
     */
    public static String generate(int length) {
        if (length <= 0) {
            throw new IllegalArgumentException("长度必须大于 0");
        }

        // 可自定义字符集
        String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
        // SecureRandom 比 Random 更安全(推荐用于 token、验证码等)
        SecureRandom random = new SecureRandom();

        return random.ints(length, 0, chars.length())
                .mapToObj(chars::charAt)
                .collect(StringBuilder::new, StringBuilder::append, StringBuilder::append)
                .toString();
    }
}

最后使用参数解析器:

java
@ExtendWith(RandomStringParameterResolver.class)
public class TestDemo {

    @Test
    void randomStringTest(@RandomString(length = 5) String randomString){
        System.out.println(randomString);
        Assertions.assertEquals(5, randomString.length());
    }

}

运行以上测试,会发现参数成功解析,测试通过。

CAUTION

注意,如果多个参数解析器都能解析同一个参数,那么会抛出ParameterResolutionException异常。

例如,再定义一个字符串参数解析器:

java
public class StringParameterResolver extends TypeBasedParameterResolver<String> {

    @Override
    public String resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
        return "";
    }
}

然后使用:

java
@ExtendWith(RandomStringParameterResolver.class)
@ExtendWith(StringParameterResolver.class)
public class TestDemo {

    @Test
    void randomStringTest(@RandomString(length = 5) String randomString){
        System.out.println(randomString);
        Assertions.assertEquals(5, randomString.length());
    }

}

运行测试会报错。

5. 异常处理

在测试运行过程中,有两类地方都可能抛出异常:

  • 在测试方法(@Test)中可能抛出异常;
  • 在生命周期方法(@BeforeAll@BeforeEach等)中可能抛出异常;

因此,针对两类不同阶段抛出的异常,在JUnit中也有两个扩展接口用来处理抛出的异常:

  • TestExecutionExceptionHandler:用来处理在测试方法中抛出的异常;
  • LifecycleMethodExecutionExceptionHandler:用来处理在生命周期方法中抛出的异常;

例如,下面的组件忽略了测试方法中抛出的IO异常:

java
public class IgnoreIOExceptionExtension implements TestExecutionExceptionHandler {

	@Override
	public void handleTestExecutionException(ExtensionContext context, Throwable throwable)
			throws Throwable {

		if (throwable instanceof IOException) {
			return;
		}
		throw throwable;
	}
}

测试如下:

java
public class ExceptionHandlerDemo {

    @Test
    @ExtendWith(IgnoreIOExceptionExtension.class)
    void test01() throws IOException {
        throw new IOException();
    }
}

运行上述测试,发现测试通过了,也就是说,测试方法中的异常被IgnoreIOExceptionExtension处理了。

如果在生命周期方法中发生了异常,那么我们可以在异常处理器中进行记录,以便后续排查问题,例如:

java
class RecordStateOnErrorExtension implements LifecycleMethodExecutionExceptionHandler {

	@Override
	public void handleBeforeAllMethodExecutionException(ExtensionContext context, Throwable ex)
			throws Throwable {
		memoryDumpForFurtherInvestigation("Failure recorded during class setup");
		throw ex;
	}

	@Override
	public void handleBeforeEachMethodExecutionException(ExtensionContext context, Throwable ex)
			throws Throwable {
		memoryDumpForFurtherInvestigation("Failure recorded during test setup");
		throw ex;
	}

	@Override
	public void handleAfterEachMethodExecutionException(ExtensionContext context, Throwable ex)
			throws Throwable {
		memoryDumpForFurtherInvestigation("Failure recorded during test cleanup");
		throw ex;
	}

	@Override
	public void handleAfterAllMethodExecutionException(ExtensionContext context, Throwable ex)
			throws Throwable {
		memoryDumpForFurtherInvestigation("Failure recorded during class cleanup");
		throw ex;
	}
}

6. 结果处理

结果处理扩展并不是改变测试结果,例如将测试不通过变为测试通过。

在JUnit中,结果处理负责在测试达到特定状态(成功、失败、跳过、中止)时记录日志、收集数据或执行清理工作,没有权利修改测试的最终结果。

TestWatcher是结果处理扩展接口,定义如下:

java
public interface TestWatcher extends Extension {

	default void testDisabled(ExtensionContext context, Optional<String> reason) {
		/* no-op */
	}

	default void testSuccessful(ExtensionContext context) {
		/* no-op */
	}

	default void testAborted(ExtensionContext context, @Nullable Throwable cause) {
		/* no-op */
	}

	default void testFailed(ExtensionContext context, @Nullable Throwable cause) {
		/* no-op */
	}

}
  • testDisabled:在测试被禁用后调用;
  • testSuccessful:在测试成功后调用;
  • testAborted:在测试终止后调用;
  • testFailed:在测试失败后调用;

例如,自定义结果处理扩展,用于记录测试成功数:

java
public class MyTestWatcher implements TestWatcher {

    private AtomicInteger successCount = new AtomicInteger(0);

    @Override
    public void testSuccessful(ExtensionContext context) {
        TestWatcher.super.testSuccessful(context);
        int i = successCount.incrementAndGet();
        System.out.println("testSuccessful: " + i);
    }
}

使用如下:

java
@ExtendWith(MyTestWatcher.class)
public class TestDemo {

    @Test
    void test01(){
    }

    @Test
    void test02(){
    }

}

7. 拦截器

InvocationInterceptor 调用拦截器是 JUnit 扩展模型中功能最强大、权限最高的接口之一。

如果说其他的 Callback 接口(如 BeforeEachCallback)只是在测试执行的间隙“打个招呼”,那么 InvocationInterceptor 就是**“拦截并接管”了整个执行过程。它允许通过包装(Wrapping)**的方式,控制测试方法、生命周期方法甚至构造函数的调用。

InvocationInterceptor 允许在以下调用发生时介入:

  • 测试类实例的构造方法;
  • @BeforeAll / @BeforeEach / @AfterEach / @AfterAll 方法;
  • @Test 方法;

在拦截器方法中,关键参数如下:

  • Invocation<T> invocation:代表拦截的操作。调用 proceed() 会返回结果 T(对于测试方法通常是 Void)。
  • ReflectiveInvocationContext<T> invocationContext:提供了被调用方法的反射信息、目标实例以及参数列表。
  • ExtensionContext extensionContext:提供 JUnit 的上下文元数据。

TIP

Each method in this class must call InvocationInterceptor.Invocation.proceed() or InvocationInterceptor.Invocation.skip() exactly once on the supplied invocation. Otherwise, the enclosing test or container will be reported as failed.

The default implementation calls proceed() on the supplied invocation.

注意,在InvocationInterceptor拦截器接口中的方法,只能调用一次invocation.proceed()invocation.skip(),否则,测试会失败。

例如,下面的拦截器用于切换线程:

java
public class ThreadSwitchInterceptor implements InvocationInterceptor {

    @Override
    public void interceptTestMethod(Invocation<Void> invocation,
                                    ReflectiveInvocationContext<Method> invocationContext,
                                    ExtensionContext extensionContext) throws Throwable {
        
        // 1. 创建一个单线程的执行器
        ExecutorService executor = Executors.newSingleThreadExecutor(r -> new Thread(r, "My-Custom-Thread"));

        try {
            // 2. 将 invocation.proceed() 提交到该线程执行
            Future<Void> future = executor.submit(() -> {
                System.out.println("当前执行线程: " + Thread.currentThread().getName());
                try {
                    invocation.proceed(); // 在新线程中运行测试方法逻辑
                } catch (Throwable e) {
                    throw new RuntimeException(e);
                }
                return null;
            });

            // 3. 阻塞主线程,等待子线程结果,并处理异常
            try {
                future.get(); 
            } catch (ExecutionException e) {
                // 必须抛出原始异常,否则 JUnit 无法捕获测试失败
                throw e.getCause();
            }
        } finally {
            executor.shutdown();
        }
    }
}

使用:

java
public class TestDemo {
    @Test
    @ExtendWith(ThreadSwitchInterceptor.class)
    void test01(){
        System.out.println(Thread.currentThread().getName());
    }

    @Test
    void test02(){
        System.out.println(Thread.currentThread().getName());
    }

}

结果:

txt
当前执行线程: My-Custom-Thread
My-Custom-Thread
main

可以发现,test01切换了线程,test02在主线程。

8. 注册扩展组件

在JUnit中,有三种方式注册扩展组件:@ExtendWith@RegisterExtension,Java服务发现机制。

8.1 @ExtendWith

@ExtendWith可以用在测试接口、测试类、测试方法或其他注解上,用来注册一个或多个扩展组件。

@ExtendWith同样可以用在字段、方法参数上(包括构造方法、测试方法、生命周期方法)。

为单个测试方法注册扩展组件:

java
@Test
@ExtendWith(WebServerExtension.class)
void getProductList() {
	
}

如果要为某个测试类中的所有方法注册扩展组件,那么可以在测试类上使用:

java
@ExtendWith(WebServerExtension.class)
class MyTests {
	
}

可以同时注册多个扩展组件:

java
@ExtendWith({ DatabaseExtension.class, WebServerExtension.class })
class MyFirstTests {
	
}

TIP

如果有多个扩展组件,那么这些组件的注册顺序,是按照声明顺序进行注册的。

可以在注解上使用@ExtendWith

java
@Target({ ElementType.TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith({ DatabaseExtension.class, WebServerExtension.class })
public @interface DatabaseAndWebServerExtension {
}

这样只需要使用@DatabaseAndWebServerExtension即可:

java
@DatabaseAndWebServerExtension
class MyFirstTests {
	
}

在有些情况下,扩展组件也可以注册在字段或方法参数上,例如,假设RandomNumberExtension扩展可以产生一个随机数,并且注入到字段或方法参数中。

首先定义一个注解@Random,使用@ExtendWith(RandomNumberExtension.class)元标记:

java
@Target({ ElementType.FIELD, ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(RandomNumberExtension.class)
public @interface Random {
}

然后在需要随机数的地方,就可以使用@Random注解了:

java
class RandomNumberDemo {

    // 静态变量
	@Random
	private static Integer randomNumber0;

    // 实例变量
	@Random
	private int randomNumber1;

    // 构造方法
	RandomNumberDemo(@Random int randomNumber2) {
		
	}

    // 生命周期方法
	@BeforeEach
	void beforeEach(@Random int randomNumber3) {
		
	}

    // 测试方法
	@Test
	void test(@Random int randomNumber4) {
		
	}

}

RandomNumberExtension实现如下:

  • BeforeAllCallback:支持静态变量的注入;
  • TestInstancePostProcessor:支持实例变量的注入;
  • ParameterResolver:支持方法参数的注入;
Details
java
import static org.junit.platform.commons.support.AnnotationSupport.findAnnotatedFields;

import java.lang.reflect.Field;
import java.util.function.Predicate;

import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolver;
import org.junit.jupiter.api.extension.TestInstancePostProcessor;
import org.junit.platform.commons.support.ModifierSupport;

class RandomNumberExtension
		implements BeforeAllCallback, TestInstancePostProcessor, ParameterResolver {

	private final java.util.Random random = new java.util.Random(System.nanoTime());

	@Override
	public void beforeAll(ExtensionContext context) {
		Class<?> testClass = context.getRequiredTestClass();
		injectFields(testClass, null, ModifierSupport::isStatic);
	}

	@Override
	public void postProcessTestInstance(Object testInstance, ExtensionContext context) {
		Class<?> testClass = context.getRequiredTestClass();
		injectFields(testClass, testInstance, ModifierSupport::isNotStatic);
	}

	@Override
	public boolean supportsParameter(ParameterContext pc, ExtensionContext ec) {
		return pc.isAnnotated(Random.class) && isInteger(pc.getParameter().getType());
	}

	@Override
	public Integer resolveParameter(ParameterContext pc, ExtensionContext ec) {
		return this.random.nextInt();
	}

	private void injectFields(Class<?> testClass, @Nullable Object testInstance,
			Predicate<Field> predicate) {

		predicate = predicate.and(field -> isInteger(field.getType()));
		findAnnotatedFields(testClass, Random.class, predicate)
			.forEach(field -> {
				try {
					field.setAccessible(true);
					field.set(testInstance, this.random.nextInt());
				}
				catch (Exception ex) {
					throw new RuntimeException(ex);
				}
			});
	}

	private static boolean isInteger(Class<?> type) {
		return type == Integer.class || type == int.class;
	}

}

8.2 @RegisterExtension

使用@ExtendWith进行扩展组件注册的一个缺点是,无法为扩展组件提供参数,例如WebServerExtension用于启动一个服务器,但是我们无法指定端口。

**@RegisterExtension可以用在类字段上注册扩展组件。**通过这种方式注册组件,就可以为扩展组件提供参数了(构造方法、工厂方法或构造器模式)。

静态字段

如果@RegisterExtension作用在静态字段上,那么该扩展组件会在类级别的@ExtendWith中指定的扩展组件注册之后进行注册,由于静态字段在类加载阶段就已经存在,所以扩展组件能赶上 JUnit 所有的生命周期班车,也就是和说静态字段扩展组件能支持的接口包括:

  • 类级别BeforeAllCallback, AfterAllCallback(实例字段无法支持)。
  • 实例化阶段TestInstancePostProcessor, TestInstancePreDestroyCallback
  • 方法级别BeforeEachCallback, AfterEachCallback, ParameterResolver 等。

遇到以下两种需求重叠时,需要用 static@RegisterExtension

  1. 需要扩展在测试类启动前(BeforeAll)做一些事(比如启动一个 Docker 容器)。
  2. 需要手动配置这个扩展(比如传入数据库镜像的名字、用户名密码)。
java
class GlobalDatabaseTest {
    // 必须是 static 才能实现 BeforeAll,
    // 必须用 @RegisterExtension 才能传入 "postgres:15" 参数
    @RegisterExtension
    static DbExtension db = new DbExtension("postgres:15");

    @Test
    void testConnection() {
        // ...
    }
}

实例字段

当一个扩展被定义为非静态字段(即实例字段)时,它的注册时机是:

  1. 测试类已经通过构造函数实例化之后。
  2. 所有的 TestInstancePostProcessor(处理依赖注入的组件)运行完毕之后。

因为 BeforeAllCallback(类级别)和 TestInstancePostProcessor(实例处理)在字段被注册之前就已经运行完了,所以如果这个扩展虽然实现了这些接口,但 JUnit 引擎已经错过了调用它们的机会,因此会失效。

如果扩展定义在实例字段下,以下接口不会生效

  • BeforeAllCallback / AfterAllCallback:因为类级别的生命周期已经开启。

  • TestInstancePostProcessor:因为实例已经处理完了。

  • TestInstancePreConstructCallback:因为构造函数已经跑完了。

例如:

java
class DemoTest {
    // 错误用法:BeforeAll 将永远不会执行
    @RegisterExtension
    MyExtension ext = new MyExtension(); // 非静态

    @Test
    void test() { ... }
}

class MyExtension implements BeforeAllCallback, BeforeEachCallback {
    @Override
    public void beforeAll(ExtensionContext c) {
        System.out.println("我不会被打印!"); 
    }
    @Override
    public void beforeEach(ExtensionContext c) {
        System.out.println("我会正常打印。");
    }
}

8.3 Java服务发现

JUnit还支持Java服务发现机制,用于注册扩展组件。

首先,在类路径的文件夹/META-INF/services中创建名为org.junit.jupiter.api.extension.Extension的文件,该文件中写上组件实现的全限定名称,例如:

txt
com.lee.test.config.TestExtension

之后,在JUnit配置文件junit-platform.properties开启自动注册:

properties
junit.jupiter.extensions.autodetection.enabled=true

9. 保存数据

9.1 问题演示

假设我们想记录测试运行时间,可以这样写一个扩展组件:

java
public class MyTimingExtension implements BeforeTestExecutionCallback, AfterTestExecutionCallback {

    private long startTime;

    @Override
    public void beforeTestExecution(ExtensionContext context) throws Exception {
        startTime = System.currentTimeMillis();
    }
    
    @Override
    public void afterTestExecution(ExtensionContext context) throws Exception {
        long end = System.currentTimeMillis();
        System.out.println("耗时:" + (end - startTime) + "ms");
        startTime = 0L;
    }

}

在测试开始前,记录一个开始时间,然后测试完成后,计算耗时,逻辑很简单。

注意第14行,测试完成后,将startTime重置为0(为了问题暴露地更明显)。

然后,在测试中进行使用:

java
@ExtendWith(MyTimingExtension.class)
@Execution(ExecutionMode.CONCURRENT)
public class TimingTest {
    @Test
    void test01() {
        System.out.println(Thread.currentThread().getName());
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("test01");
    }

    @Test
    void test02() {
        System.out.println(Thread.currentThread().getName());
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        System.out.println("test02");
    }
}

WARNING

注意,使用了并发运行测试,关于如何设置并发测试,后续会讲解。

运行上述测试,结果如下:

txt
ForkJoinPool-1-worker-2
ForkJoinPool-1-worker-3
test01
耗时:529ms
test02
耗时:1771920732625ms

可以看到,有一个耗时明显错误。这就是本节要解决的问题,如何在扩展插件中保存数据。

9.2 概述

通常情况下,扩展组件只会被初始化一次,因此,如果有多个测试需要使用该扩展组件,那么如何保证测试之间的数据隔离?在JUnit中, ExtensionContext API提供了Store用来存储数据,保证各个测试之间数据的隔离性。

image-20260224171036734

如上图所示,Store是有层级的,从下到上分别是测试方法-->测试类-->测试引擎(多个测试类),并且每个层级都通过Namespace区分不同的Store

当调用store.get(key),如果在当前层级未找到,那么会使用相同的Namespacekey向上查找,例如,假设现在是在测试方法级别的Store查找数据,如果没找到,则会向上查找测试类的Store,如果还没找到,再向上查找,直至找到或找不到。

Store关联在ExtensionContext中,当ExtensionContext结束生命周期时,关联的Store也会关闭,如果Store中存储的资源实现了AutoCloseable接口,那么此时资源会自动关闭。

除了ExtensionContext层级范围的Store,还存在两个层级的Store,分别是EXECUTION_REQUESTLAUNCHER_SESSION,可以在context.getStore()时指定StoreScope,如下:

java
context.getStore(ExtensionContext.StoreScope.LAUNCHER_SESSION, Namespace.create(getClass()))

9.3 案例:计时器

下面使用Store来改造测试方法计时器:

java
import java.lang.reflect.Method;
import java.util.logging.Logger;

import org.junit.jupiter.api.extension.AfterTestExecutionCallback;
import org.junit.jupiter.api.extension.BeforeTestExecutionCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ExtensionContext.Namespace;
import org.junit.jupiter.api.extension.ExtensionContext.Store;

public class TimingExtension implements BeforeTestExecutionCallback, AfterTestExecutionCallback {

	private static final Logger logger = Logger.getLogger(TimingExtension.class.getName());

	private static final String START_TIME = "start time";
	private static final String NAME = "name";

	@Override
	public void beforeTestExecution(ExtensionContext context) {
		getStore(context).put(START_TIME, System.currentTimeMillis());
        // 存入自动关闭资源
		getStore(context).put(NAME, new Resources(context.getDisplayName()));
	}

	@Override
	public void afterTestExecution(ExtensionContext context) {
		Method testMethod = context.getRequiredTestMethod();
		long startTime = getStore(context).remove(START_TIME, long.class);
		long duration = System.currentTimeMillis() - startTime;

		logger.info(() ->
			"Method [%s] took %s ms.".formatted(testMethod.getName(), duration));
	}

	private Store getStore(ExtensionContext context) {
		return context.getStore(Namespace.create(getClass(), context.getRequiredTestMethod()));
	}

	// 演示自动关闭资源
	public static class Resources implements AutoCloseable {

		private String name;

		public Resources(String name) {
			this.name = name;
		}

		@Override
		public void close() throws Exception {
			System.out.println("close:" + name);
		}
	}
}

10. 工具类

junit-platform-commons依赖中,JUnit提供了许多有用的工具类,用来操作注解、类、反射、类路径扫描以及类型转换:

  • AnnotationSupport:是 JUnit 提供的一个静态工具类,专门用于高效处理注解。它的核心功能可以精简概括为:
    • 全方位操作:支持对包、类、接口、构造函数、方法和字段等各种元素进行注解扫描。
    • 深度搜索:不仅能检查直接标注的注解,还能识别元注解(即标注在注解上的注解)。
    • 层级感知:支持跨越类继承体系和接口实现进行搜索,确保不会遗漏定义在父类或接口中的注解。
    • 精准查找:提供便捷方法用于快速定位类中所有带特定注解的方法或字段。
  • ClassSupport:简化对 Class 对象的操作,主要功能:
    • 格式化类名:提供静态工具方法,能将一组 Class 对象快速转换为以逗号分隔的完整限定类名(Fully Qualified Class Names)列表。
    • 简化日志与报告:主要用于扩展开发中,当需要向控制台或报告中输出多个类的信息时,避免手动编写循环和字符串拼接。
  • ReflectionSupport:负责真正的类加载、实例化和方法调用:
    • 增强型反射:对 JDK 原生反射进行了封装和增强,处理了复杂的异常捕获和类型检查。
    • 类路径扫描:支持根据指定的谓词(Predicates)在类路径中搜索符合条件的类。
    • 安全实例化:提供便捷方法来加载类并创建实例,自动处理构造函数访问权限。
    • 智能方法查找与调用:支持跨越类继承层级寻找匹配的方法,并执行调用。
  • ModifierSupport:专门用来检查类或成员(字段、方法)的修饰符状态,核心功能:
    • 状态判定:提供一系列静态工具方法,用于快速判断一个类、方法或字段是否具备特定的修饰符,如 publicprivatestaticabstractfinal 等。
    • 语义简化:将 JDK 原生复杂的位运算(如 (m.getModifiers() & Modifier.PUBLIC) != 0)封装成语义清晰的方法(如 isPublic(m))。
  • ConversionSupport:专门负责将字符串(String)转换成各种复杂的 Java 对象,支持范围极广:
    • 基础类型:所有基本类型(int, double, boolean 等)及其包装类。
    • 时间日期:java.time 包下的全家桶(LocalDate, ZonedDateTime, Duration 等)。
    • 常用工具类:File, BigDecimal, Currency, UUID, URI, URL 等。
    • 枚举类型:自动将字符串匹配到对应的 Enum 常量。

参考资料

[1] https://docs.junit.org/6.0.3/extensions/overview.html