单元测试实践思考(junit5+jmockit+testcontainer)
陈晨_软件五千言 人气:0
[TOC]
# 背景
之前整理过一篇,基于(SpringCloud+Junit5+Mockito+DataMocker)的框架整理的单元测试。当时的项目是一个编排层的服务项目,所以没有涉及到数据库或者其他中间件的复杂问题。而且是项目刚开始,代码环境不复杂,当时的架构基本上能够满足需求。
最近在一个较老项目,现在希望加强项目的代码质量,所以开始引入单元测试框架。于是乎先按照原本的设计引入了junit5的整套框架,同时引入了h2用于数据库模拟,以及rabbitmq的mock服务。这个项目使用的是SpringCloud Alibaba框架,服务注册和配置管理使用nacos,其他没有太多特别的地方。但是实际编写的过程中,发现了一些问题:
- Mock框架使用了Mockito和PowerMock,开发人员需要同时使用两种框架。
- H2的数据库和实际的Mysql数据库相比还是有一些差异,比如无法支持函数等情况。
- 单元测试的数据准备相对比较复杂,如何能够很好的隔离不同单元测试的影响是个问题。
- 单元测试是为了覆盖率还是为了有强度的质量保证,如何提高研发人员的单元测试质量。
# 方案设计
针对上述问题,我们来一条一条解决。
首先是针对Mock框架,考察之后认为可以选择Jmockit框架,能够直接满足普通方法和静态方法,但是语法相对不如Mockito自然,学习曲线相对较高。但最终还是决定尝试以统一框架来做,降低架构的复杂度。
其次是数据库问题,有两种方案,一种是完善H2数据库,可以用自定义的函数来支持缺失的特性,但缺点也很明确,H2始终不是真实的Mysql数据库。第二种找到了TestContainer方案,这是一个Java操作Docker的类库,可以利用Java代码直接生成Docker的镜像与容器并且运行,这样就有办法直接启动一个Mysql的容器用于单元测试,结束后直接完全销毁。这种方法的缺点在于环境问题,所有需要运行单元测试的环境都需要安装Docker支持,包含研发自己和CI环境。但是好处在于一个通用的中间件模拟方案,后续Redis、MQ或者其他的中间件都完全可以使用这样的方案来模拟了。
数据准备,这个问题我们设定了两种数据准备的方式。第一部分是在初始化数据库的时候,导入基础脚本,这部分的脚本包含结构和数据,是公用的内容所有的单元测试都需要依赖的基础数据,比如公司、部门、员工、角色、权限等等。第二部分是在单元测试单个类初始化时,引入数据脚本,这些数据仅仅是为了单个类/方法中的单元测试使用,运行完方法后会回滚,不会影响到其他单元测试的运行。
最后是单元测试的强度,主要还是一些规范,例如要求所有的单元测试都必须要有断言,并且断言的条件是要对数据内容字段进行合理验证的。可以参考一下这一篇[写有价值的单元测试](https://yq.aliyun.com/articles/54478)。
所以最终落定的框架就是 Junit5 + Jmockit + TestContainer。
# 单元测试指导思想
在底层框架搭建之前,可以先讨论一下如何才能写出真正有价值的单元测试,而不是单纯为了绩效中的单元测试覆盖率?
之前一段中提到的[写有价值的单元测试](https://yq.aliyun.com/articles/54478)和阿里Java代码规约中有提到一些点
>引用阿里规约:
1. 【强制】好的单元测试必须遵守 AIR原则。
说明:单元测试在线上运行时,感觉像空气(AIR)一样并不存在,但在测试质量的保障上,
却是非常关键的。好的单元测试宏观上来说,具有自动化、独立性、可重复执行的特点。 A:Automatic(自动化) I:Independent(独立性) R:Repeatable(可重复)
2. 【强制】单元测试应该是全自动执行的,并且非交互式的。测试用例通常是被定期执行的,执
行过程必须完全自动化才有意义。输出结果需要人工检查的测试不是一个好的单元测试。单元
测试中不准使用 System.out来进行人肉验证,必须使用 assert来验证。
3. 【强制】保持单元测试的独立性。为了保证单元测试稳定可靠且便于维护,单元测试用例之间
决不能互相调用,也不能依赖执行的先后次序。
反例:method2需要依赖 method1的执行,将执行结果作为 method2的输入。
4. 【强制】单元测试是可以重复执行的,不能受到外界环境的影响。
说明:单元测试通常会被放到持续集成中,每次有代码 check in时单元测试都会被执行。如
果单测对外部环境(网络、服务、中间件等)有依赖,容易导致持续集成机制的不可用。
正例:为了不受外界环境影响,要求设计代码时就把 SUT的依赖改成注入,在测试时用 spring
这样的 DI框架注入一个本地(内存)实现或者 Mock实现。
5. 【强制】对于单元测试,要保证测试粒度足够小,有助于精确定位问题。单测粒度至多是类级
别,一般是方法级别。
说明:只有测试粒度小才能在出错时尽快定位到出错位置。单测不负责检查跨类或者跨系统的
交互逻辑,那是集成测试的领域。
其中有一些思想会决定我们在单元测试代码具体的实现方式。我们尝试了之后,根据上述的指导思想有两种不同的实现方式。
- 单层隔离
- 内部穿透
接下来我们就两种方式来进行说明。
## 单层隔离
正常代码分层会分为controller、service、dao等,在单层隔离的思想中,是针对每一层的代码做各自的单元测试,不向下穿透。这样的写法主要是保证单层的业务逻辑固化且正确。
实践过程中,例如针对controller层编写的单元测试需要将对应controller类代码文件外部所有的调用全部mock,包括对应的内部/外部的service。其他层的代码也是如此。
这样做的优点:
- 单元测试代码极其轻量,运行速度快。由于只保证单个类内部的逻辑正确,其他全部mock,所以可以放弃中间件的mock,甚至Spring的注入都可以放弃,专注在单元测试逻辑验证的编写。这样整套单元测试代码运行完成应该也是轮秒计时,相对来讲Spring容器初始化完成可能都需要20秒。
- 真正符合了单元测试的原则,可以在断网的情况下进行运行。单层逻辑中可以屏蔽服务注册和配置管理,各种中间件的影响。
- 单元测试质量更高。针对单层逻辑的验证和断言能够更加清晰,如果要覆盖多层,可能会忽略丢失中间的各种验证环节,如果加上可能条件规模是一个笛卡尔乘积过于复杂。
缺点也是存在:
- 单元测试的代码量比较大,因为是针对每层单独编写单元测试,而且需要mock掉的外部依赖也是比较多的。
- 学习曲线相对较高,由于程序员的习惯针对单元测试是给定输入验证输出。所以没有了底层的输出,单纯验证过程逻辑要存在一个思维上的转变。
- 对于低复杂度的项目比较不友好。如果你的项目大部分都是单纯的分层之后的CRUD,那单元测试其实可验证的东西不太多。但是如果是代码当中执行了复杂逻辑,这样的写法就能够起到比较好的质量保证。
在这个项目中,最终没有采用这样的方法,而是采用了穿透的方式。项目的场景、人员组成、复杂度的实际情况,我觉得用这种方式不算很合适。
## 内部穿透
穿透,自然就是从顶层一直调用到底层。为什么还要加上内部二字?就是除了项目内的方法可以穿透,项目外部依赖还是要mock掉的。
实践过程中,就是单元测试针对controller层编写,但是会完整调用service、dao,最终对落地结果进行验证。
优点:
- 代码量相对较小,由于进行了穿透所以多层代码的覆盖仅需要从顶层的单元测试验证即可。
- 学习曲线低,穿透的单元测试更偏向黑盒,开发人员构造输入条件,然后从落地结果中(存储,例如数据库)验证预期结果。
缺点:
- 整体较重,启动Spring容器,中间件mock,整体单元测试运行预计需要是需要分钟级别。所以基本是要在CI的时候来执行。
# 技术实现
敲定方案之后我们就可以进行技术实现了,这是一个Java项目,使用Maven进行依赖管理。接下来我们主要分为三部分介绍:
- 依赖管理
- 基础架构
- 实现实例
## 依赖管理
依赖管理中第一个注意的点,由于目前Junit4还占有较多的市场,我们要尽量去排除掉一些测试相关的依赖中包含对与4的引用。
接下来我先贴出Pom文件中和单元测试相关的部分
```xml
```
依赖的引入基本就是这些了,其中还需要注意的是surefire的插件配置
```xml
```
这里的注意点是Jmockit需要使用javaagent来初始化JVM参数。
## 基础架构
基础架构的部分,我想分为三点来讲:
- 单元测试基类,封装了一些项目使用的基础Mock对象和公用方法
- 单元测试配置相关
- TestContainer的封装
其实这三点都是与单元测试基类相关的,分开讲各自的实现方式后,最终会给出完整的代码。
### 封装Junit5&Jmockit
首先是注解的部分Junit4到5注解有调整和变化,而且我们的项目又是基于SpringCloud的,所以最终的单元测试基类BaseTest使用了三个注解
```java
@SpringBootTest(classes = {OaApplication.class}, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Transactional
@Slf4j
```
Junit5的类头部是不需要什么注解的,主要还是和Spring配合,我们使用了Boot Test提供的SpringBootTest注解,指定了入口的启动类,为了包含配置文件,获取nacos配置。
事务注解是为了让数据操作方法都能够回滚,不影响其他单元测试。
最后就是lombok的日志注解。
接下来就是BeforeAll,AfterAll,BeforeEach,AfterEach几个注解。
这里的思路就是使用Jmockit,对待测试业务系统内底层机制进行统一的Mock处理,例如request或者session中的头部信息。我这里的代码可能和大家各自的项目中差异比较多,只是提供一个思路。利用Jmockit来Mock我们一些静态方法获取对象时,直接返回我们设计的结果对象。
```java
@BeforeAll
protected static void beforeAll() {
new MockUp
加载全部内容