
《JUnit in Action》全新第3版封面截图
写在前面
本章从 JUnit 5 新增的
Extension API模型中摘取了几个常见场景进行重点介绍。根据最新的 JUnit 官方文档,Extension API模型接口已经扩充到了 16 个之多;但是有了本章的案例,其他接口也很快能触类旁通。为了方便实测,我又补充了不使用 Extension 接口的常规版本作为上篇进行对照;下篇重点梳理提到了几个应用场景。
第四部分:现代框架与 JUnit 5 实战
该板块是全书的核心内容------
- 第 14 章介绍
JUnit 5全新的扩展(extension)模型; - 第 15 章介绍表现层测试及
HtmlUnit、Selenium工具的使用; - 第 16、17 章介绍
Spring、SpringBoot中JUnit 5的用法; - 第 18 章介绍
REST风格接口应用的测试; - 第 19 章介绍数据库应用的测试(
JDBC、Spring、Hibernate)。
第十四章:JUnit 5 扩展模型
本章概要
JUnit 5扩展(extension)的创建方法;- 利用扩展点实现
JUnit 5测试用例的方法;- 学会基于
JUnit 5扩展模型生成的测试用例开发应用程序。
The wheel is an extension of the foot, the book is an extension of the eye, clothing an extension of the skin, electric circuitry an extension of the central nervous system.轮子是脚的延伸,书籍是眼睛的延伸,衣物是皮肤的延伸,电路是中枢神经系统的延伸。
------ Marshall McLuhan
14.1 JUnit 5 扩展模型概述
JUnit 4 通过 Runners 和 Rules 提供扩展接口,JUnit 5 则通过全新的 Extension API 接口。Extension 本身只是一个 标记接口(marker interface) ,也叫 标签接口(tag interface) 、令牌接口(token interface) ,其内部不包含任何字段或接口方法,只用于标识其实现类具备某种特定行为,如 Serializable、Cloneable 接口等。
扩展模型的基本原理:JUnit 5 扩展的具体实现逻辑,可以关联到测试执行过程中的某个特定事件的发生节点,即 扩展点(extension point) 。当测试声明周期抵挡该节点时,JUnit 引擎就会自动调用这些扩展逻辑。
JUnit 5 提供了以下几类扩展点:
- 条件测试执行(Conditional test execution):根据某个判定条件决定测试是否应该运行;
- 生命周期回调(Life-cycle callback):响应测试的生命周期内的特定事件;
- 参数解析(Parameter resolution):解析测试运行时接收到的参数;
- 异常处理(Exception handling):在测试遇到特定类型的异常时,定义测试的行为;
- 测试实例后处理(Test instance postprocessing):在测试实例创建后需要执行的具体逻辑。
JUnit 5 的扩展逻辑常被框架或构建工具内部调用,也可用于应用开发,只是程度有限。
14.2 示例一:定制判定条件选择性执行测试
思路:实现 org.junit.jupiter.api.extension.ExecutionCondition 接口,并重写 evaluateExecutionCondition() 方法。
示例从资源目录下的配置文件 context.properties 读取参数 context 的值,并通过验证这个值是否在指定的值域范围内(regular、low)来决定被标注的测试类或方法是否执行:
java
public class ExecutionContextExtension implements ExecutionCondition {
@Override
public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {
Properties properties = new Properties();
String executionContext = "";
try {
properties.load(getClass().getClassLoader().getResourceAsStream("context.properties"));
executionContext = properties.getProperty("context");
if (!"regular".equalsIgnoreCase(executionContext) && !"low".equalsIgnoreCase(executionContext)) {
return ConditionEvaluationResult.disabled("Test disabled outside regular and low contexts");
}
} catch (IOException e) {
throw new RuntimeException(e);
}
return ConditionEvaluationResult.enabled("Test enabled on the " + executionContext + " context");
}
}
// 用法:
@ExtendWith({ExecutionContextExtension.class})
class PassengerTest {
// -- snip --
}
这些被标记的测试类或方法在 IDEA 中按类似 @Disabled 注解的方式跳过测试逻辑。以上一章演示过的 Passenger 乘客实体类为例,快速构建一个包含三个测试方法的测试类 PassengerDemoTest:
java
public class PassengerDemoTest {
private static void runTestLogics() {
Passenger passenger = new Passenger("123-456-789", "John Smith");
assertEquals("Passenger John Smith with identifier: 123-456-789", passenger.toString());
}
@Test
@ExtendWith(ExecutionContextExtension.class)
@DisplayName("marked with conditional extension (1)")
void testPassenger1() {
runTestLogics();
}
@Test
@ExtendWith(ExecutionContextExtension.class)
@DisplayName("marked with conditional extension (2)")
void testPassenger2() {
runTestLogics();
}
@Test
@DisplayName("no conditional extension")
void testPassenger3() {
runTestLogics();
}
}
运行该测试类,IDEA 将跳过被标注的测试方法(效果如同添加了 @Disabled 注解):

此外,IDEA 还支持通过 虚拟机参数 来禁用这些判定条件,参数写法为:
bash
-Djunit.jupiter.conditions.deactivate=*
实测 IDEA 最新版中的设置界面(IntelliJ IDEA 2025.2.4 (Ultimate Edition)):

实测效果:

14.3 示例二:实体类持久化到数据库的 CRUD 操作测试
这个例子是全章的重点,也很有现实意义。演示的业务需求,是要将乘客实体类 Passenger 存入某个数据库,然后用 JUnit 5 创建单元测试。
14.3.1 实现数据持久化逻辑
乘客实体类 Passenger 的具体逻辑不变:
java
public class Passenger {
private String identifier;
private String name;
public Passenger(String identifier, String name) {
this.identifier = identifier;
this.name = name;
}
public String getIdentifier() {
return identifier;
}
public String getName() {
return name;
}
@Override
public String toString() {
return "Passenger " + getName() + " with identifier: " + getIdentifier();
}
}
为了方便演示,书中使用的工具链为:H2 + JDBC + JUnit 5 扩展。
为此,需要在 pom.xml 中引入内存数据库 H2 的依赖(之前的版本 1.4.199 太旧了,实测时升级到最新的 2.4.240):
xml
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.4.240</version>
</dependency>
然后创建工具类 ConnectionManager,利用传统的 JDBC API 实现连接数据库的基础逻辑:
java
public class ConnectionManager {
private static Connection connection;
public static Connection getConnection() {
return connection;
}
public static Connection openConnection() {
try {
Class.forName("org.h2.Driver"); // this is driver for H2
connection = DriverManager.getConnection("jdbc:h2:~/passenger",
"sa", // login
"" // password
);
return connection;
} catch (ClassNotFoundException | SQLException e) {
throw new RuntimeException(e);
}
}
public static void closeConnection() {
if (null != connection) {
try {
connection.close();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
}
}
只创建连接还不够,还得提前建好一张乘客表 PASSENGERS 用于增删改查操作;测试结束后再删除该表(也可以保留,根据具体需求确定),相关逻辑放到另一个工具类 TablesManager:
java
public class TablesManager {
public static void createTable(Connection connection) {
String sql = "CREATE TABLE IF NOT EXISTS PASSENGERS (ID VARCHAR(50), NAME VARCHAR(50));";
executeStatement(connection, sql);
}
public static void dropTable(Connection connection) {
String sql = "DROP TABLE IF EXISTS PASSENGERS;";
executeStatement(connection, sql);
}
private static void executeStatement(Connection connection, String sql) {
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
}
然后是数据访问层逻辑,包含一个 DAO 接口和对应实现。接口层 PassengerDao 定义了 CRUD 四个方法:
java
// 接口层
public interface PassengerDao {
void insert(Passenger passenger);
void update(String id, String name);
void delete(Passenger passenger);
Passenger getById(String id);
}
然后实现该接口,并通过构造函数传参的方式注入数据库连接:
java
public class PassengerDaoImpl implements PassengerDao {
private Connection connection;
public PassengerDaoImpl(Connection connection) {
this.connection = connection;
}
@Override
public void insert(Passenger passenger){
String sql = "INSERT INTO PASSENGERS (ID, NAME) VALUES (?, ?)";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, passenger.getIdentifier());
statement.setString(2, passenger.getName());
statement.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
@Override
public void update(String id, String name) {
String sql = "UPDATE PASSENGERS SET NAME = ? WHERE ID = ?";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, name);
statement.setString(2, id);
statement.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
@Override
public void delete(Passenger passenger) {
String sql = "DELETE FROM PASSENGERS WHERE ID = ?";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, passenger.getIdentifier());
statement.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
@Override
public Passenger getById(String id) {
String sql = "SELECT * FROM PASSENGERS WHERE ID = ?";
Passenger passenger = null;
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, id);
ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
passenger = new Passenger(resultSet.getString(1), resultSet.getString(2));
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
return passenger;
}
}
这样功能模块就实现好了,下一步该编写测试用例了。
14.3.2 不用 Extension API 的单元测试写法
按照正常逻辑,测试类中也要注入一个 PassengerDao 的依赖,然后在每个测试方法内依次执行下列步骤:
- 连接数据库、数据表:交给添加了
@BeforeAll注解的生命周期方法; - 初始化数据库:交给添加了
@BeforeEach注解的生命周期方法; - 执行
CRUD测试逻辑;测试方法的核心逻辑; - 断言
CRUD执行结果:测试方法的核心逻辑; - 还原数据库操作:交给添加了
@AfterEach注解的生命周期方法; - 清空数据表、关闭数据库连接:交给添加了
@AfterAll注解的生命周期方法。
上述步骤除了 3 和 4 外,其余都是配合核心逻辑的辅助工作,如果不演示 Extension API 的用法,测试用例应该写成这样:
java
public class PassengerDiyTest {
private static Connection connection;
private Savepoint savepoint;
private PassengerDao passengerDao;
@BeforeAll
static void setUp() {
connection = ConnectionManager.openConnection();
TablesManager.dropTable(connection);
TablesManager.createTable(connection);
}
@AfterAll
static void tearDown() {
TablesManager.dropTable(connection);
if (null != connection) {
try {
connection.close();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
}
@BeforeEach
void setUpEach() throws SQLException {
passengerDao = new PassengerDaoImpl(connection);
connection.setAutoCommit(false);
savepoint = connection.setSavepoint("savepoint");
}
@AfterEach
void tearDownEach() throws SQLException {
connection.rollback(savepoint);
}
@Test
void testPassenger() {
Passenger passenger = new Passenger("123-456-789", "John Smith");
assertEquals("Passenger John Smith with identifier: 123-456-789", passenger.toString());
}
@Test
void testInsertPassenger() {
Passenger passenger = new Passenger("123-456-789", "John Smith");
passengerDao.insert(passenger);
assertEquals("John Smith", passengerDao.getById("123-456-789").getName());
}
@Test
void testUpdatePassenger() {
Passenger passenger = new Passenger("123-456-789", "John Smith");
passengerDao.insert(passenger);
passengerDao.update("123-456-789", "Michael Smith");
assertEquals("Michael Smith", passengerDao.getById("123-456-789").getName());
}
@Test
void testDeletePassenger() {
Passenger passenger = new Passenger("123-456-789", "John Smith");
passengerDao.insert(passenger);
passengerDao.delete(passenger);
assertNull(passengerDao.getById("123-456-789"));
}
}
实测效果也是没问题的:

(上篇完)