1. 概述

JUnit 5 相比旧版本带来了诸多新特性,其中一项便是 测试模板(Test Templates)。简单来说,测试模板是对 JUnit 5 中参数化测试和重复测试的一种更强大的抽象和扩展。

本文将带你掌握如何在 JUnit 5 中创建并使用测试模板,解决一些参数化测试搞不定的复杂场景。

2. Maven 依赖

首先,在 pom.xml 中引入必要的依赖。

需要添加 JUnit 5 的核心引擎和 API 依赖:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-engine</artifactId>
    <version>5.10.2</version>
</dependency>
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-api</artifactId>
    <version>5.10.2</version>
</dependency>

当然,如果你用的是 Gradle,对应的配置如下:

testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.10.2'
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.2'

✅ 建议直接使用 junit-jupiter 的 BOM 来统一版本管理,避免版本冲突。

3. 问题背景

在介绍测试模板前,先回顾下 JUnit 5 的 参数化测试(Parameterized Tests)。它允许我们为一个测试方法传入多组参数,从而实现“一次编写,多次执行”。

但参数化测试有个明显局限:它只能变参数,不能变执行上下文(invocation context)

举个实际场景:

我们希望同一个测试方法能以不同的方式运行多次,每次的“环境配置”都不同,比如:

  • 使用不同的输入参数 ✅
  • 测试实例的初始化方式不同(比如注入不同的依赖) ✅
  • 根据环境动态启用/跳过某些执行(比如在 QA 环境下禁用) ✅
  • 每次执行前后执行不同的生命周期回调(比如某些执行需要启停数据库) ✅

这时候参数化测试就无能为力了。而测试模板正是为此类需求设计的——它允许你为每次执行定制独立的上下文。

4. 测试模板详解

测试模板本身不是测试用例,而是生成测试用例的“模板”。它的核心思想是:每个执行上下文触发一次模板方法的执行

关键角色包括:

  • 测试目标方法:被测逻辑
  • 测试模板方法:用 @TestTemplate 标记的方法
  • 上下文提供者(Invocation Context Provider):提供多个执行上下文
  • 执行上下文(Invocation Context):定义每次执行的具体行为和扩展

下面我们通过一个完整示例来拆解。

4.1. 测试目标方法

我们以一个简单的用户 ID 生成器为例:

public class UserIdGeneratorImpl implements UserIdGenerator {
    private boolean isFeatureEnabled;

    public UserIdGeneratorImpl(boolean isFeatureEnabled) {
        this.isFeatureEnabled = isFeatureEnabled;
    }

    public String generate(String firstName, String lastName) {
        String initialAndLastName = firstName.substring(0, 1).concat(lastName);
        return isFeatureEnabled ? "bael".concat(initialAndLastName) : initialAndLastName;
    }
}

逻辑很简单:

  • 功能关闭时:John SmithJSmith
  • 功能开启时:John SmithbaelJSmith

4.2. 测试模板方法

public class UserIdGeneratorImplUnitTest {
    
    @TestTemplate
    @ExtendWith(UserIdGeneratorTestInvocationContextProvider.class)
    public void whenUserIdRequested_thenUserIdIsReturnedInCorrectFormat(
        UserIdGeneratorTestCase testCase) {
        
        UserIdGenerator userIdGenerator = new UserIdGeneratorImpl(testCase.isFeatureEnabled());
        String actualUserId = userIdGenerator.generate(testCase.getFirstName(), testCase.getLastName());
        
        assertThat(actualUserId).isEqualTo(testCase.getExpectedUserId());
    }
}

关键点:

  • @TestTemplate:标记这是一个模板方法
  • @ExtendWith:注册上下文提供者
  • ✅ 参数 UserIdGeneratorTestCase:接收上下文提供的测试数据

测试数据封装类:

public class UserIdGeneratorTestCase {
    private boolean isFeatureEnabled;
    private String firstName;
    private String lastName;
    private String expectedUserId;
    private String displayName;

    // 构造函数和 getter/setter 省略
}

4.3. 上下文提供者

上下文提供者需实现 TestTemplateInvocationContextProvider 接口:

public class UserIdGeneratorTestInvocationContextProvider 
    implements TestTemplateInvocationContextProvider {

    @Override
    public boolean supportsTestTemplate(ExtensionContext context) {
        return true;
    }

    @Override
    public Stream<TestTemplateInvocationContext> provideTestTemplateInvocationContexts(
        ExtensionContext context) {
        
        boolean featureDisabled = false;
        boolean featureEnabled = true;

        return Stream.of(
            featureDisabledContext(new UserIdGeneratorTestCase(
                "Given feature switch disabled When user name is John Smith Then generated userid is JSmith",
                featureDisabled, "John", "Smith", "JSmith")),
            featureEnabledContext(new UserIdGeneratorTestCase(
                "Given feature switch enabled When user name is John Smith Then generated userid is baelJSmith",
                featureEnabled, "John", "Smith", "baelJSmith"))
        );
    }
}
  • supportsTestTemplate:返回 true 表示该提供者适用于当前测试
  • provideTestTemplateInvocationContexts:返回多个执行上下文,每个上下文触发一次模板执行

4.4. 执行上下文实例

每个 TestTemplateInvocationContext 可以定义:

  • getDisplayName:自定义测试显示名称
  • getAdditionalExtensions:注册本次执行特有的扩展

场景一:功能关闭时的上下文

private TestTemplateInvocationContext featureDisabledContext(
    UserIdGeneratorTestCase testCase) {
    
    return new TestTemplateInvocationContext() {
        @Override
        public String getDisplayName(int invocationIndex) {
            return testCase.getDisplayName();
        }

        @Override
        public List<Extension> getAdditionalExtensions() {
            return asList(
                new GenericTypedParameterResolver(testCase),
                new BeforeTestExecutionCallback() {
                    @Override
                    public void beforeTestExecution(ExtensionContext context) {
                        System.out.println("BeforeTestExecutionCallback:Disabled context");
                    }
                },
                new AfterTestExecutionCallback() {
                    @Override
                    public void afterTestExecution(ExtensionContext context) {
                        System.out.println("AfterTestExecutionCallback:Disabled context");
                    }
                }
            );
        }
    };
}

场景二:功能开启时的上下文

private TestTemplateInvocationContext featureEnabledContext(
    UserIdGeneratorTestCase testCase) {
    
    return new TestTemplateInvocationContext() {
        @Override
        public String getDisplayName(int invocationIndex) {
            return testCase.getDisplayName();
        }

        @Override
        public List<Extension> getAdditionalExtensions() {
            return asList(
                new GenericTypedParameterResolver(testCase),
                new DisabledOnQAEnvironmentExtension(), // QA 环境下跳过
                new BeforeEachCallback() {
                    @Override
                    public void beforeEach(ExtensionContext context) {
                        System.out.println("BeforeEachCallback:Enabled context");
                    }
                },
                new AfterEachCallback() {
                    @Override
                    public void afterEach(ExtensionContext context) {
                        System.out.println("AfterEachCallback:Enabled context");
                    }
                }
            );
        }
    };
}

⚠️ 重点来了:

  • 同一个测试方法被执行了 两次
  • 每次执行的 扩展(Extension)完全不同
    • 第一次:使用 BeforeTestExecutionCallback
    • 第二次:使用 BeforeEachCallback + 环境判断扩展
  • 第二次执行在 QA 环境下会被自动跳过

这就是测试模板的强大之处——灵活控制每次执行的上下文,远超参数化测试的能力。

5. 总结

测试模板是 JUnit 5 中一个被低估但极其强大的特性,适用于:

  • ✅ 需要不同初始化逻辑的多场景测试
  • ✅ 动态启用/禁用某些执行路径
  • ✅ 复杂的生命周期管理(如数据库启停)
  • ✅ 组合多种扩展行为

相比参数化测试,它提供了上下文级别的控制粒度,适合解决更复杂的测试场景。

项目源码已托管至 GitHub:https://github.com/yourname/junit5-test-templates-demo


原始标题:Writing Templates for Test Cases Using JUnit 5