使用 Spock + DBUnit + H2 测试组件进行 MyBatis-Plus 持久层单元测试
1. 前言
单元测试相关理论知识内容繁多,本文旨在于介绍基于 Spock + DBUnit + H2 测试套件下的 MyBatis-Plus 持久层单元测试的工程实践,故而不会过多介绍单元测试相关理论知识,只会点出一些笔者认为重要的与本文相关的单元测试前置知识。
2. 单元测试的 AIR 原则
AIR 原则可以指导我们判断当前工程项目中的单元测试用例编写是否是正确的、符合业界最佳实践的。
A:Automatic(自动化)
即单元测试具备可自动化运行的能力,测试用例通常是被定期执行的,执行过程必须完全自动化才有意义。如: 集成 CI 能力,在每一次 commit 过后,在 pipeline 中进行各项单元测试集合的自动化运行(不准使用 System.out 来进行人肉验证,必须使用 assert 来验证),并给出自动化单元测试运行的结果(pass/fail)。
I:Independent(独立性)
为了保证单元测试稳定可靠且便于维护,单元测试用例之间决不能互相调用,也不能依赖执行的先后次序;单元测试也不依赖任何外部的系统或组件(如外部系统、外部 MySQL、外部密钥等),而是自身具备独立可测试的能力,方便于任何第三方拿到单测之后都可以直接运行并验证功能。
R:Repeatable(可重复)
单元测试应该是可重复的,每次运行的运行都应该不应该受到外部系统或者外部数据影响。一旦数据有可用性问题,结果就会像幽灵一样时好时坏;也不能因为这次创建了数据,下次就因为主键约束不能再创建。
3. 测试组件介绍
3.1 Spock 是什么?
Spock 是一种基于 Groovy 的开源单元测试框架。它结合了 JUnit 和 Mockito 的优势,为 Java 和 Groovy 程序员提供了一种强大的测试解决方案。以下是 Spock 单元测试框架的主要特点:
- 声明式测试
Spock 提供了一种声明式的方式来描述测试场景,即使用given-when-then
块来组织测试代码,并使用相应的标签注释每个块。这种方式使得测试代码易于阅读和理解,也可以让测试结果更加清晰地表达出来。
- 数据驱动测试
Spock 支持数据驱动测试,即为同一个测试场景提供多组数据进行测试。可以使用where
块来定义测试数据,使用@Unroll
注释来展开数据,从而生成多个测试用例。这种方式可以大幅度地减少测试代码的重复性,提高测试效率。
- 灵活的 Mock 支持
Spock 内置了 Mock 支持,可以使用Mock()
和Stub()
方法来创建 Mock 对象。此外,Spock 还提供了Interaction Based Testing
来测试 Mock 对象和真实对象之间的交互,使得测试 Mock 对象变得更加灵活和方便。
- 丰富的断言支持
Spock 提供了丰富的断言支持,包括基本的相等和不等关系,以及更高级的容器断言和异常断言。这些断言可以让测试代码更加精确、简洁和易于维护。
- 简单易用的语法
Spock 使用 Groovy 语言编写,具有简单易用的语法,可以减少测试代码的冗余度,提高测试代码的可读性和可维护性。
总之,Spock 单元测试框架是一种功能强大、易于使用的测试解决方案,它的特点包括声明式测试、数据驱动测试、灵活的 Mock 支持、丰富的断言支持和简单易用的语法。
3.2 DBUnit 是什么?
DBUnit 是一个 Java 测试工具,用于进行数据库单元测试。它能够帮助开发人员在执行单元测试时,快速地准备数据库测试数据和期望结果,并进行比较和验证。DBUnit 支持通过数据集和 XML 格式定义测试数据和期望结果,可以与 JUnit、TestNG 等测试框架集成使用。DBUnit 还提供了方便的 API 和工具,可以简化测试代码的编写和维护。
3.3 H2 是什么?
H2 是一种基于 Java 的嵌入式关系型数据库管理系统 (RDBMS),它采用纯 Java 编写,以嵌入式方式运行于 Java 应用程序中。H2 提供了快速高效的数据库操作,并且支持标准 SQL 和 JDBC API,同时还提供了内存模式和磁盘模式两种运行方式,非常适合作为测试和开发环境中的轻量级数据库。
4. 测试方案回顾与对比
首先我们先一起回顾一下过往的持久层单元测试实践
第一种方案
方案描述
@SpringBootTest
启动整个 Spring 容器 + 直连测试库 + 不处理单元测试用例运行所造成的测试库数据污染
方案分析
- 直接使用
@SpringBootTest
注解启动测试用例,利用 Spring 创建 Mybatis Mapper 实例,这种方式并不属于单元测试,而是集成测试范畴了,因为当启用@SpringBootTest
时,会把整个应用的上下文加载进来。不仅耗时时间长,而且一旦依赖环境上有任何问题,可能会影响启动,进而影响持久层的测试 - 单元测试应该遵循 AIR 原则,避免产生脏数据是一项基本要求,该方案违反了要求(独立性、可重复),不是好的单元测试实践
第二种方案
方案描述
@SpringBootTest
启动整个 Spring 容器 + 直连测试库 + 使用回滚注解@Rollback 配合事务注解@Transactional 来回滚事务
方案分析
- 仍存在单元测试用例启动运行时间长、单测效率低、依赖环境多、单测运行易失败不够独立的问题
- 使用回滚注解@Rollback 配合事务注解@Transactional 回滚事务解决了单测用例运行期间造成的脏数据问题,满足了 AIR 原则中的可重复原则。不过仍然依赖了外部 MySQL 组件,一旦无法成功连接到 MySQL 则单测用例无法运行,即违反了 AIR 原则中的独立性原则
第三种方案
方案描述
@SpringBootTest
启动整个 Spring 容器 + 使用 H2 内存数据库
方案分析
- 同方案一、二共同存在的单测耗时长、效率低、依赖多易失败不独立的问题
- 因为运行时连接的数据库替换成了 H2 内存数据库,所以不用再使用稍微有点别扭的、不太优雅的事务回滚方式清理脏数据了。同时因为是内嵌数据库,也不用再担心外部存储组件故障或者网络中断导致的单测用例运行失败。
第四种方案
方案描述:
- 不启动整个 Spring 容器(通过 MyBatis 的 SqlSession 启动 mapper 实例,避免通过 Spring 启动加载上下文信息)
- 使用 H2 内存数据库,隔离大家的数据库连接(完全隔离 不会存在互相干扰的现象)
- 通过 DBUnit 工具,用作对于数据库层的操作访问工具
- 通过扩展 Spock 的注解,提供对于数据库 Schema 创建和数据 Data 加载的方式(xml 方式)
方案分析
-
不启动整个 Spring 容器,只加载必要的类对象,单测运行速度快、效率高
-
不依赖外部 MySQL 组件,满足独立性原则。使用 H2 内存数据库隔离大家的数据库连接,数据不会相互污染,满足可重复原则。
总结:
使用该测试套件作为持久层测试方案,其实是为了让我们的单元测试用例符合AIR 原则,用例运行更快、更独立、可重复运行、无副作用
5. 测试组件集成
5.1 预期效果
- 单元测试用例执行之前先在内存数据库H2中创建相关库表
- 使用指定数据集初始化H2相关表数据
- 单元测试用例执行完毕后恢复现场
- 不启动Spring容器,单元测试用例运行时间快
5.2 实现原理
5.2.1 Spock的Fixture Methods中的setup方法、cleanup方法
setup方法会在每个单元测试用例执行前执行,cleanup方法会在每个单元测试用例执行后执行
在setup方法中执行SQL脚本创建H2库表,在cleanup方法中清理H2表
定义测试用例基类MyBaseSpec,存放测试用例公共操作代码
本文示例数据表的DDL
数据字典表 - data_dict
5.2.2 Spock提供的自定义扩展机制 Annotation Driven Local Extensions和Interceptors
测试用例上使用自定义注解@MyDBUnit指定数据集文件位置和H2 schema,数据集格式为DBUnit XML形式,可以用IDEA或者DataGrip的DBUnit Extractor插件快速提取
在@MyDBUnit注解类中使用@ExtensionAnnotation(MyDbUnitExtension.class) 指定我们自定义的扩展类
在自定义扩展类中主要是为了设置拦截器,这边拦截了那些标注了@MyDBUnit注解的Spock Feature方法,Feature方法即普通的测试用例方法。
当普通的测试用例方法执行之前,会执行下面的initData方法,使用给定的数据集初始化H2相关库表数据
另外,关于扩展类的编写也可以参考Spock内建扩展的写法
5.2.3 使用DBUnit的FlatXmlDataSet提供初始化数据
5.2.4 Mybatis SqlSessionFactory获取Mapper对象
参考MybatisPlusAutoConfiguration自动配置类中Mapper对象初始化过程
MapperUtil工具类
Mybatis-Plus DALService 注入BaseMapper
H2数据源
6. 测试用例示例
class DataDictMapperSpec extends MyBaseSpec {
def dataDictMapper = MapperUtil.getMapper(DataDictMapper.class,false)
/**
* 测试数据准备,通常为sql表结构创建用的ddl,支持多个文件以逗号分隔。
*/
def setup() {
println("setup")
executeSqlScriptFiles("schema/schema.sql")
}
/**
* 数据表清除,通常待drop的数据表
*/
def cleanup() {
println("cleanup")
dropTables("unit_test.data_dict")
}
@Ignore
@MyDbUnit(xmlLocation = "data/dataset.xml", schema = "UNIT_TEST")
def "测试-获取数据字典列表"() {
when:
def dataDictEntity = dataDictMapper.selectOne(new QueryWrapper<DataDictEntity>()
.eq("dict_type_code", "product_importance_level")
.eq("dict_item_code", "4")
)
then:
dataDictEntity.getDictItemName() == "4"
}
@Unroll
@MyDbUnit(xmlLocation = "data/dataset.xml",schema = "UNIT_TEST")
def "参数化测试"() {
given:
def dataDictEntity = dataDictMapper.selectById(id)
expect:
dataDictEntity.getDictItemCode() == dictItemCode
where:
id || dictItemCode
1 || "1"
2 || "2"
3 || "3"
}
}
附录2中给出示例代码的github链接,有兴趣在项目中使用该测试方案的人员可以自行下载
7. 附录
转载自:https://juejin.cn/post/7214068367606825021