
《JUnit in Action》全新第3版封面截图
写在前面
本篇为第十四章笔记的下篇,重点介绍 JUnit 5 全新的扩展接口(Extension API)的几个典型应用场景。作者针对常见的数据库持久化模块进行了详细的单元测试演示,并结合 Extension API 接口的几个典型场景不断改造原有测试逻辑,非常具有参考价值,值得用心体会。
(接上篇)
14.3.3 利用 Extension API 的测试用例写法
对照常规写法不难发现:除了核心测试逻辑外,还有大量的样板代码需要处理:测试开始前的数据库连接、DAO 实例的初始化、临时数据表的管理......无形中加重了开发者的认知负担,例如在操作数据库前必须让测试用例完全控制事务的开启和关闭,以免对数据库造成污染。
而利用 JUnit 5 提供的 生命周期回调扩展点 ,就能用扩展类的方式将这些样板代码的处理逻辑从测试逻辑中抽离出来,从而简化单元测试的书写。具体来说,可以创建一个实现类 DatabaseOperationsExtension 同时继承四个生命周期注解对应的扩展点接口:
java
public class DatabaseOperationsExtension implements BeforeAllCallback, AfterAllCallback, BeforeEachCallback, AfterEachCallback {
private Connection connection;
private Savepoint savepoint;
@Override
public void beforeAll(ExtensionContext context) {
connection = ConnectionManager.openConnection();
TablesManager.dropTable(connection);
TablesManager.createTable(connection);
}
@Override
public void afterAll(ExtensionContext context) {
TablesManager.dropTable(connection);
ConnectionManager.closeConnection();
}
@Override
public void beforeEach(ExtensionContext context) throws SQLException {
connection.setAutoCommit(false);
savepoint = connection.setSavepoint("savepoint");
}
@Override
public void afterEach(ExtensionContext context) throws SQLException {
connection.rollback(savepoint);
}
}
这样就率先封装了 connection 和 savepoint 实例。
至于数据访问对象 passengerDao,可以通过构造函数的参数传入测试类。这就需要 JUnit 5 能正确解析 PassengerDao 型的参数,通过手动实现另一个扩展点 ParameterResolver 接口即可:
java
public class DataAccessObjectParameterResolver implements ParameterResolver {
@Override
public boolean supportsParameter(ParameterContext paramCtx, ExtensionContext extCtx) throws ParameterResolutionException {
return paramCtx.getParameter()
.getType()
.equals(PassengerDao.class);
}
@Override
public Object resolveParameter(ParameterContext paramCtx, ExtensionContext extCtx) throws ParameterResolutionException {
return new PassengerDaoImpl(ConnectionManager.getConnection());
}
}
同理,甚至每个测试方法中手动创建的 passenger 实例对象也可以通过方法参数的形式注入(只是不推荐这样写罢了):
java
public class PassengerParameterResolver implements ParameterResolver {
@Override
public boolean supportsParameter(ParameterContext paramCtx, ExtensionContext extCtx) throws ParameterResolutionException {
return paramCtx.getParameter()
.getType()
.equals(Passenger.class);
}
@Override
public Object resolveParameter(ParameterContext paramCtx, ExtensionContext extCtx) throws ParameterResolutionException {
return new Passenger("123-456-789", "John Smith");
}
}
通过扩展改造后的测试类如下:
java
@ExtendWith({DataAccessObjectParameterResolver.class,
DatabaseOperationsExtension.class,
PassengerParameterResolver.class})
public class PassengerExtTest {
private final PassengerDao passengerDao;
public PassengerExtTest(PassengerDao passengerDao) {
this.passengerDao = passengerDao;
}
@Test
void testPassenger(Passenger passenger) {
assertEquals("Passenger John Smith with identifier: 123-456-789", passenger.toString());
}
@Test
void testInsertPassenger(Passenger passenger) {
passengerDao.insert(passenger);
Passenger passenger1 = passengerDao.getById("123-456-789");
assertEquals("John Smith", passenger1.getName());
}
@Test
void testUpdatePassenger(Passenger passenger) {
passengerDao.insert(passenger);
passengerDao.update("123-456-789", "Michael Smith");
assertEquals("Michael Smith", passengerDao.getById("123-456-789").getName());
}
@Test
void testDeletePassenger(Passenger passenger) {
passengerDao.insert(passenger);
passengerDao.delete(passenger);
assertNull(passengerDao.getById("123-456-789"));
}
}
实测效果也和刚才一样,但是核心逻辑更加突出:

填坑备忘:轻信 AI 补全的代价
在实现
Passenger的参数解析接口时,由于相信AI工具的补全能力,不慎将resolveParameter()方法的返回值写成了return new Passenger("John Smith", "123-456-789");(name和identifier的位置颠倒了),结果运行始终报错。可见信任不能代替监督,AI补全的内容一定要检查一遍。
14.4 示例四:利用抛自定义异常来测试乘客记录的唯一性
至此,开头提到的五类扩展点已经演示了三个,本例再演示一个:在测试用例中使用自定义的异常。场景很简单,插入一条新数据前先判定是否已存在。即改造 insert() 接口签名和实现类的逻辑:
java
// 修改接口签名
public interface PassengerDao {
// -- snip --
void insert(Passenger passenger) throws PassengerExistsException ;
// -- snip --
}
// 修改接口实现
public class PassengerDaoImpl implements PassengerDao {
// -- snip --
@Override
public void insert(Passenger passenger) throws PassengerExistsException {
String sql = "INSERT INTO PASSENGERS (ID, NAME) VALUES (?, ?)";
if (null != getById(passenger.getIdentifier())) {
throw new PassengerExistsException(passenger, passenger.toString());
}
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);
}
}
// -- snip --
}
其中第 L16 行的自定义异常实现如下:
java
public class PassengerExistsException extends Exception {
private final Passenger passenger;
public PassengerExistsException(Passenger passenger, String message) {
super(message);
this.passenger = passenger;
}
}
然后同步更新各测试用例,凡是调用过 insert() 方法的用例都加上 throws 字句,同时新增一个用例专门测试重复插入的报错情况:
java
@ExtendWith({DataAccessObjectParameterResolver.class,
DatabaseOperationsExtension.class,
PassengerParameterResolver.class
})
public class PassengerExtTest {
private final PassengerDao passengerDao;
public PassengerExtTest(PassengerDao passengerDao) {
this.passengerDao = passengerDao;
}
@Test
void testPassenger(Passenger passenger) {
assertEquals("Passenger John Smith with identifier: 123-456-789", passenger.toString());
}
@Test
void testInsertPassenger(Passenger passenger) throws PassengerExistsException {
passengerDao.insert(passenger);
assertEquals("John Smith", passengerDao.getById("123-456-789").getName());
}
@Test
void testUpdatePassenger(Passenger passenger) throws PassengerExistsException {
passengerDao.insert(passenger);
passengerDao.update("123-456-789", "Michael Smith");
assertEquals("Michael Smith", passengerDao.getById("123-456-789").getName());
}
@Test
void testDeletePassenger(Passenger passenger) throws PassengerExistsException {
passengerDao.insert(passenger);
passengerDao.delete(passenger);
assertNull(passengerDao.getById("123-456-789"));
}
@Test
void testInsertExistingPassenger(Passenger passenger) throws PassengerExistsException {
passengerDao.insert(passenger);
passengerDao.insert(passenger);
assertEquals("John Smith", passengerDao.getById("123-456-789").getName());
}
}
正常情况下,最后一个用例会抛出自定义异常的报错原因,以及相应的堆栈信息:

如果想按指定的格式输出报错信息,例如只显示自定义的报错原因,不打印堆栈信息,这时就必须利用 JUnit 5 的 异常处理 扩展点,实现其 TestExecutionExceptionHandler 接口并重写 handleTestExecutionException() 方法:
java
public class LogPassengerExistsExceptionExtension implements TestExecutionExceptionHandler {
private final Logger logger = Logger.getLogger(getClass().getName());
@Override
public void handleTestExecutionException(ExtensionContext context, Throwable throwable) throws Throwable {
if (throwable instanceof PassengerExistsException) {
logger.severe("Passenger exists:" + throwable.getMessage());
return;
}
throw throwable;
}
}
上述逻辑在抛出自定义的异常时只打印一段错误日志就结束,不含任何堆栈信息。然后将这个实现类放入 @ExtendWith 注解中让扩展逻辑生效:
java
@ExtendWith({DataAccessObjectParameterResolver.class,
DatabaseOperationsExtension.class,
PassengerParameterResolver.class,
LogPassengerExistsExceptionExtension.class
})
public class PassengerExtTest {
// -- snip --
}
再次运行,报错输出的就是指定内容了:

注意:由于测试用例执行到第二句 passengerDao.insert(passenger); 就抛异常了,且异常处理逻辑是扩展定制的内容,输出日志后直接 return 结束了,因此该用例也不会判定为执行失败。
14.5 小结
本章相对全面地演示了 JUnit 5 全新的 Extension API 在扩展单元测试功能特性方面的诸多应用,包括选择性执行单元测试、扩展生命周期回调接口取代相应的生命周期注解方法、自定义参数解析器让测试类的构造函数或测试方法能正确解析传入的参数,最后还通过 去重校验逻辑 扩展了适用于单元测试场景下的异常处理逻辑。至于最后一种(测试实例的后处理)可能因为和前面的案例有所重叠,书中并没有进行演示(也不排除是后面内容的伏笔)。
除了本章提过的五类扩展类型外,JUnit 5 还提供了大量其他的扩展接口与应用场景,详见 JUnit 官方文档:https://docs.junit.org/current/user-guide/#extensions。
总之,通过重写各生命周期回调接口的方法,可以让单元测试的书写更加突出重点,应用得当有望大幅减少开发者编写样板代码的工作量,值得一试。