【JUnit实战3_24】 第十四章:JUnit 5 扩展模型(Extension API)实战(下)

《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);
    }
}

这样就率先封装了 connectionsavepoint 实例。

至于数据访问对象 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");nameidentifier 的位置颠倒了),结果运行始终报错。可见信任不能代替监督,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

总之,通过重写各生命周期回调接口的方法,可以让单元测试的书写更加突出重点,应用得当有望大幅减少开发者编写样板代码的工作量,值得一试。

相关推荐
安全不再安全4 小时前
免杀技巧 - 早鸟注入详细学习笔记
linux·windows·笔记·学习·测试工具·web安全·网络安全
西游音月5 小时前
(2)pytest+Selenium自动化测试-环境准备
selenium·测试工具·pytest
明月与玄武9 小时前
Postman 的汉化安装中文版及使用指南!
测试工具·postman·postman汉化
程序员杰哥10 小时前
Fiddler抓包手机和部分app无法连接网络问题
自动化测试·软件测试·python·测试工具·智能手机·fiddler·测试用例
我的xiaodoujiao11 小时前
使用 Python 语言 从 0 到 1 搭建完整 Web UI自动化测试学习系列 23--数据驱动--参数化处理 Yaml 文件
python·学习·测试工具·pytest
workflower15 小时前
测试套件缩减方法
数据库·单元测试·需求分析·个人开发·极限编程
要一杯卡布奇诺17 小时前
测开百日计划——Day1
功能测试·测试工具·单元测试·集成测试
千里镜宵烛1 天前
深入 Lua 元表与元方法
junit
安冬的码畜日常1 天前
【JUnit实战3_27】第十六章:用 JUnit 测试 Spring 应用:通过实战案例深入理解 IoC 原理
spring·观察者模式·设计模式·单元测试·ioc·依赖注入·junit5