【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

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

相关推荐
软件检测小牛玛20 小时前
具备软件功能测试资质的机构哪家更权威?山东软件测评机构 中承信安
功能测试·单元测试·软件测试报告·软件测评机构
may_一一21 小时前
xpath定位:selenium和playwrightAnt Design / 表单类页面)
selenium·测试工具
daopuyun21 小时前
CNAS/CMA软件检测实验室源代码漏洞测试工具选型要求与比对
软件测试·测试工具·软件检测·cnas认可·cma认定
Wpa.wk1 天前
接口自动化测试 - 请求构造和响应断言 -Rest-assure
开发语言·python·测试工具·接口自动化
闻哥1 天前
从测试坏味道到优雅实践:打造高质量单元测试
java·面试·单元测试·log4j·springboot
AI_56781 天前
Postman接口测试提速技巧:批量请求+智能断言实践
测试工具·lua·postman
Luminbox紫创测控1 天前
整车自然暴晒与全光谱阳光模拟老化相关性研究
测试工具
Warren981 天前
Pytest Fixture 作用域与接口测试 Token 污染问题实战解析
功能测试·面试·单元测试·集成测试·pytest·postman·模块测试
知行合一。。。1 天前
程序中的log4j、stderr、stdout日志
python·单元测试·log4j