关于单元测试的实践总结
公司开发文档中讲述
单元测试是对最小功能单元编写测试代码,以判定实际结果与期望结果是否一致。
测试驱动开发的理念提倡在具体实现代码前编写单元测试代码,在编写实现代码的过程中进行单元测试。此外,编写完实现代码后再进行单元测试也是常用的方式。长期来看,单元测试可以提高代码质量,减少维护成本。单元测试不是越多越好,而是越有效越好,单元测试应该覆盖逻辑复杂且易出错的代码、核心业务代码以及公共代码等。
这里以Java 语言为例,介绍单元测试的框架和规范。Java 中最小功能单元是方法,因此对Java 程序的单元测试是对单个Java 方法进行正确性检验。如果修改了某个方法的代码,一般需确保能够通过相应的单元测试才可认为改动正确。
单元测试框架
JUnit 是一个开源的Java 语言单元测试框架,已成为了一个标准的Java 单
元测试平台,支持高效编写单元测试代码,生成单元测试报告。
(1) 环境配置
Spring Boot 的Spring-boot-starter-test 启动器提供了JUnit、Spring Test、
AssertJ 等常见单元测试框架。首先在pom 文件中引入spring-boot-start-test 依
赖,然后创建测试目录src/test/java,在此目录下编写以Test 作为后缀来命名的
测试类。
(2) 使用断言
在测试方法内部,常用的方式是使用断言,通过断言方法将实际结果与期
望结果进行对比。断言方法在Assertions 类中定义,主要的断言方法有
assertEquals()、assertTrue()、assertFalse()、assertNotNull()等。
(3) 使用Mock
在进行单元测试的过程中,可能会依赖于外部系统的接口。这时可以通过
构造一个虚拟的mock 对象来模拟外部接口,即mock 测试。
单元测试规范
单元测试应采用的基本准则如下。
(1) 单元测试类应命名为测试类名+Test,测试方法以test 开头。
(2) 单元测试代码必须写在src/test/java 目录中。
(3) 单元测试代码本身必须非常简单明了,不能再为测试代码编写测试代码。
(4) 好的单元测试必须遵守自动化、独立性和可重复执行的原则。单元测试中必须使用断言(assert)来验证,而不是使用System.out 来进行人工验证。
(5) 对于单元测试,要保证测试粒度足够小,有助于精确定位问题。单元测试粒度至多是类级别,一般是方法级别。
(6) 新增代码应及时补充单元测试。新增代码影响原有单元测试时需及时修正。
(7) 编写单元测试的代码应与设计文档相结合,并进行边界值测试,需同时确保正确输入和错误输入时都能得到预期结果。
(8) 数据库增删改查操作,不能假设数据库里存在数据,或直接操作数据库插入数据,请使用程序插入或者导入数据的方式来准备数据。数据库相关的单元测试应设定自动回滚机制。
此外,在本地编写代码时可以通过运行单元测试得到验证,单元测试应是可以重复执行的,不能受到外部环境变化的影响。单元测试通常会被放到持续集成中,每次有代码变更时都会被执行。如果单元测试对网络、服务中间件等外部环境有依赖,容易导致持续集成机制的不可用。为了不受外界环境变化的影响,设计代码时可把依赖改成注入,在测试时用Spring 这样的DI 框架注入一个本地(内存)实现或者Mock 实现。
理想情况下单元测试应覆盖所有类和逻辑,实际过程中应保证对重要代码的覆盖。建议单元测试应覆盖到代码中复杂或容易出错的算法、逻辑、功能。
单元测试的覆盖率
可以使用idea自带的功能按钮进行检查
前面的启动配置选择要检查的单元测试,就可以得到类似如下的
![单元测试覆盖率
这里展示了所在包中的类,类的覆盖率,方法的覆盖率,代码行的覆盖率
JAVA单元测试的编写
单元测试还要分成单元测试
和集成测试
单元测试-概念定义
单元测试是指对软件中的最小可测试单元进行检查和验证
单元测试是别写测试代码,用来检测特定的、明确的、细颗粒的功能。单元测试并不一定保证程序是正确的,更不保证整体业务是准确的。
单元测试不仅仅又来保证当前代码的正确性,更重要的是用来保证代码修复、改进或重构后的正确性
单元测试不验证应用程序代码与外部依赖的正常工作。聚焦与单个组件,并mock所有与依赖组件的交互;
特点
- 不依赖任何模块。
- 基于代码测试,不要在容器中运行,比如springApplicationContext中。
- 方法执行快,秒级(不需要依赖容器启动)
- 同一单元测试可以重复执行N次,并且结果相同,一致性
一般关注点
- 接口功能测试:用来保证接口功能的正确
- 局部数据结构测试:用来保证接口汇总的数据结构是正确的
- 比如变量有误初始化
- 变量是否溢出
- 边界条件测试
- 变量赋值:NULL
- 数值
- 溢出边界:最大值,最小值
- 临近边界:最大值-1,最小值+1
- 字符串
- 空字符串
- 长度边界
- 集合
- 空集合
- 集合大小边界
- 顺序性
- 所有独立执行通路测试:保证每一条代码,每一个分支都经过测试
- 代码覆盖率**(直接参考IDE提供的覆盖率即可)**
- 行覆盖率:每行代码都测试到
- 分支覆盖:每个分支都测试到
- 条件覆盖:宽限定的分支覆盖
- 异常路径(错误情况):保证每一个异常都经过测试(IO异常,中断异常等难以做到)
- 代码覆盖率**(直接参考IDE提供的覆盖率即可)**
编写
在测试方法的编写中,和正常的程序编写有很大的区别,最主要的区别在于:断言,mock
测试编写有不同风格(assert和BDD流式风格),这里介绍我常用的,不做其他探索
常用的mock方式
返回值mock
User user = ...;
// 省略很多定义...
when(userMapper.queryById(0L)).thenReturn(user);
// case 1
User result = userService.query(1L);
// case 2
User result = userService.query(0L);
如上示例中,我们使用 when(x).thenReturn(xx)
来模拟确定when里面的方法调用会返回什么样的结果,这里必须参数一致,否则会抛出异常或者返回null或者进行真实方法执行,这个取决于被调用的对象是mock或者spy等不同方式,这里case2就会按照前面定义返回指定user
异常mock
doThrow(new RuntimeException("A")).when(userMapper).queryPage()
如上,就会在调用userMapper.queryPage()
时抛出异常;如果抛出的不是RuntimeException,则必须是在方法上申明的检查异常
静态方法mock
一般的mock方式都是对实例对象的方法调用,我们都可以使用对象代理、继承的方式进行;但是静态方式就不那么容易了,因此较少有组件支持,mockito就是其一,使用静态mock需要升级mock版本到3.4以上,比如如下
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>3.6.28</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-inline</artifactId>
<version>3.6.28</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>3.4.0</version>
<scope>test</scope>
</dependency>
示例如下
try(MockedStatic<AopContext> mockedStatic = Mockito.mockStatic(AopContext.class)){
mockedStatic.when(AopContext::currentProxy).thenReturn(jgbsValidateService);
R result = jgbsValidateService.startValidate("jgbs", "C1006", "20211001");
Assert.assertEquals(SysErrorCodeEnum.XBL0000.name(),result.getReturnCode());
}
MockedStatic 是实现了AutoCloseable ,因此一般用try()
代码块来关闭,在代码块以外,mock就失效了
常用的断言校验
结果验证
assertThat(actual).isEqualTo(expected);
assertThat(actual).isTrue();
assertThat(actual).//以及...
其他调用可以通过IDE提示来看,基本见名知意
调用验证
List<Integer> mockList = mock(List.class);
//run
mockList.add(1);
mockList.add(2);
// 校验 add(1) 被调用一次
verify(mockList).add(1);
// 校验 add(任何参数) 被调用2次
verify(mockList,times(2)).add(anyInt());
// 校验指定异常
final List<Integer> list = null;
NullPointerException nullPointerException =
Assertions.assertThrows(NullPointerException.class, () -> list.get(10));
其他verify 的使用参数参考验证mode的各种实现
单元测试注解
@Ignore
标识这个类或者这个方法不需要测试
@Test
最基础的注解,表明该方法是一个普通测试方法,不能有返回值,不能使private
和static
@RepeatedTest
可以替代@Test
但是该注解标识该测试方法需要重复执行
@BeforeEach,@AfterEach
和@Test
同级,标识该方法需要在每个测试用例执行前、之后执行
@BeforeAll,@AfterAll
在整个测试类中只会执行一次,必须为static,在所有方法执行前、后
@TestInstance
类注解,用于区分 测试方法对实例状态的改变而影响其他测试方法,通过@TestInstance(TestInstance.lifecyclePER_CLASS)
标注使得所有测试方法公用同一个实例,默认Lifecycle.PER_METHOD
,每个方法创建一个实例
@Timeout
类或者方法注解,限制执行时间,如果超过指定时间没有执行完成,那么测试用例是失败的
@DispalyName
显示名称,在IDE中重新定义显示的名称
@Disable
标识禁止改测试类或者方法运行
辅助插件(试用)
这里向单元测试没有经验的同事,推荐一个idea插件【Squaretest】,这个插件提供了多种方式的单元测试自动生成模板,并且在一定程度上自动产生某些分支问题的测试方法,为我们编写单元测试提供参考的模板以及基本范例。个人推荐使用 JUnit5MockitoSpringAssertJ.java.ft模板。
使用时,在需要生成测试用例的类右键
在下面两个选项中选择,第一个是普通的生成,第二个是需要选择mock成员的,然后根据大家代码的不同情况会生成除测试用例,有一些规则如下:
- 会对所有的
public
方法都生成测试方法; - 对mock的成员方法调用如果返回值是集合类型,则会生成
ReturnsNoItems
的测试方法,用于测试mock的方法调用返回空对象; - 自动生成的测试数据,这里会直接把变量名赋值给字符串属性,需要自行根据情况调整
- 需要测试的类会被作为Test类的成员属性定义,根据情况生成
setUp
方法 - 多数测试方法按照
setUp
,Configure method invoke
,Run the test
,Verify the results
四步进行的
一般情况下,生成的测试方法都需要一定调整;
单元测试对代码质量的要求
有时候我们会发现为一些特定代码编写好单元测试是很困难的,实际上,单元测试应该是简单的,使单元测试变得苦难并引入复杂性的真正原因是设计不良,难以测试的代码。比如,如果一个需要测试的方法长达数百行,处理了非常多的逻辑,那么,相应的测试方法,测试类,也会要处理非常多的入参数据、出参数据、分支条件以及逻辑;因此,编写单元测试也对代码设计提出了要求;
好的单元测试
前面我们指出单元测试的特点,这里需要在提出好的单元测试的特点
- 易写 容易编写大量单元测试来覆盖不同的情况(分支)
- 可读 意图明确,失败时易于定位和检测问题
- 可靠 只有测试代码有问题才会失败,不应该因为环境,等其他问题而失败
- 快速 快速执行
- 单元测试,不是集成测试 不依赖外部,不会因为外部影响失败,比如数据库,文件等
可测试代码 VS 不可测试代码
有些代码在编写的时候开发者没由遵守合理的代码规范,以至于很难,甚至不可能为这些代码写出好的单元测试。我们需要重点避免一些反模式,代码异味,和不良实践
存在非确定性的代码
比如有个方法,需要根据现在的时间来决定生成不同的报告(季报的第一二三四季度),有一个方法就写成如下伪代码;
String bgzq() {
Date data = new Date();
if(data.getMonth() < 3){
return "first";
}else if(data.getMonth() < 6){
return "second";
}else if(data.getMonth() < 9){
return "third";
}else {
return "fourth";
}
}
该方法就难以编写单元测试,因为这里获取日期本质上是一个隐藏的输入,会在程序执行期或者测试期发生变化。体现了以下几个问题:
- 与具体数据源紧密耦合,方法中的date是来自执行代码的计算机的
- 违反单一职责原则,该方法本质做了两件事,获取信息(时间),处理信息(处理日期)
- 隐藏了工作所需要的信息(参数,或者说依赖关系),如果开发者,不阅读方法的每一行代码,仅凭方法签名是无法预料到其行为的
- 难以预测和维护,基于前几点可以看出,即便阅读了源码,如果不知道该方法真实运行的时间,也无法预它会返回什么结果,这里的时间等同于一个全局变量,该方法的结果是依赖了时间的
改造方法1
在这个DEMO中很简单,我们需要做的就是增加一个入参,把获取获取日期独立出去,这样,获取日期和本方法都变得易于测试了。即便在更高层的调用上还是会聚合这些逻辑。
改造方法2
按照面向对象的思路,同理扩展,如果我们依赖的是数据库,或者更复杂的逻辑产生的数据,我们还可以使用控制反转的思路,构建一个包含获取数据(比如这里日期)的接口,通过依赖注入来确定这个数据的产生,这样在我们进行测试时,对该依赖进行mock即可进行测试
改造方式3
例子总结
没有不确定性的代码是更容易被测试的,它们表现为纯函数,无状态,他们只表示行为而不表示状态,这类方法是易于测试的;而存在不确定性的方法在应用中的危害是可以被扩大的,比如A方法存在不确定性,然后B方法又依赖A方法的场景;
这里状态是JAVA中无状态的概念,个人认为含义是相近的
但是不确定性或者说状态的存在也是不可避免的,任何应用都会在某些时候必须与环境,数据库,文件等外部交互,我们没法消除它们,我们要限制这些因素,尽可能消灭掉被硬编码的依赖关系,一遍能够独立的分析各部件和进行单元测试
对于其他需要避免的点
反模式:遵循面向对象的流原则进行开发,单一职责,开闭原则,里氏替换,依赖倒置,接口隔离,最小知识
代码异味:通过IDE的智能检测提醒,编译器警告,sonar扫描等手段改进
常见的警告标志
静态属性
静态属性或者字段,即全局变量;读取或者修改全局变量的函数是不纯的。
单例
单例模式本质上就是一种全局状态,不恰当使用可能会隐藏真正的依赖关系,并在组件之间引入不必要的耦合。另外单例模式一定程度违反单一职责的,它还控制着对象的初始化和生命周期。最好用容器的依赖注入来避免直接耦合。
结合项目组内组件以及代码检视过程中的问题
依赖注入
由于我们组使用za21框架,框架提供了,com.cmb.bee.persistence.autoconfig.util.DBUtil
对象,之前我们在使用时都是以如下代码形式使用
class xxService{
@Autowired
DBUtil dbUtil;
public String query(){
XxxMapper mapper = dbUtil.getMapper(XxxMapper.class);
mapper.query();
BMapper bMapper = dbUtil.getMapper(BMapper.class);
bMapper.query();
}
}
这里面对于这种方式,我们会额外需要对dbUtil做一次mock,是无必要的繁琐操作,如果服务类中有多个mapper使用,更是麻烦
不过这里还是需要大家结合各自的历史因素:由于之前有使用动态数据源的场景,不仅是系统使用了Mysql,还需要有通过Impala查询的场景,需要使用DbUtil来获取对应数据源的查询;但是后来弃用Impala的方案了。因此,我们认为,完全可以放弃使用dbUtil的方式,直接注入我们需要的Mapper,使测试用例易于编写
变量作用域的控制
在代码检视过程中经常看到一类代码逻辑
public String doA(){
List<xxx> list = new List<>();
// 省略N行代码
doB(xxxx,list);
}
void doB(Xxx arg,List<xxx> list){
// 省略N行业务代码
list.add(xxx);
}
实例代码中,将一个集合对象作为参数传递到了堆栈的下层方法中去,实际只是为了传递一下参数;如果按面向过程编程来看,好像是没有问题,但在面向对象编程中违反了【最小知识原则】;除非lsit中的内容是下面的B方法需要读取的,否则不应该将整个list传递给下层调用,正确的写法应该在b方法中构建一个新的集合,通过返回值来返回在B阶段的数据,然后在A方法中通过addAll()
汇总;
public String doA(){
List<xxx> list = new List<>();
// 省略N行代码
Data data= doB(xxxx,list);
}
Data doB(Xxx arg,List<xxx> list){
// 省略N行业务代码
list.add(xxx);
}
更有甚者,可能出现一个方法需要的返回数据有两个乃至多个,这种情形就不适用上面的说法,但不是没有问题,而是问题更大了,这里面就要考虑是对业务逻辑的理解存在问题,或者对业务的概念模型封装存在问题,还是对代码设计的流程有问题。如果是概念模型封装问题,比如实际上这里返回的Data和list在当前业务场景上是个互相耦合互相绑定的东西,那就应该封装类,把Data和List组成一个对象;
过长的代码块
在MVC的分层模型下,很多时候一个会让service层承担所有的业务逻辑代码,而在写代码的时候,按照请求的链路,很自然的会按照面向过程编程思路,把操作的所有步骤按照顺序一步步写下来,一不小心就完成了一个很长的service方法。
对于此类代码,我们按照代码规范或者sonar的要求进行整改,比如if/for/try 层级深度,分支复杂度等规则整改,即可处理大多数问题;
应用合理的设计模式
在信息系统有多个这样的需求,通过配置不同规则来筛选产品需要的配置,并且筛选规则是有优先级的,比如产品分类,一级分类优先级低于二级分类,低于三级分类,类似这样的条件组合配置不同优先级的规则,然后通过规则匹配来筛选产品;在筛选的时候,可以找到多个不同优先级的规则,最高优先级的规则无法筛选到配置,则优先使用,没有则使用低优先级的在进行查找,直到找到。
对于该类需求,最早的功能实现中有通过三层循环来特殊处理7层优先级规则的匹配,这样的实现不仅难于理解,更难于测试以及调试,当面对需求变更时,无疑要面临整块功能的重构;
那我们后续是如何解决的呢?在深入理解分析需求,以及对未来可能的变动做一定的预判,我们用策略模式+责任链模式 很好的处理了这个实现,每一个层级的规则作为独立的策略编写独立的实现,然后按照优先级顺序组装责任链,依次调用;
后续新添加规则层级时,能够不影响其他功能的情况下,仅通过添加一个规则策略的实现,就达成了需求;
public RepEmailDO match(RepTgDO repTgDO) {
for (EmailForReportMatchStrategy matcher:matcherChain){
//具体每个规则的匹配实现都封装到策略中
RepEmailDto repEmailDto = matcher.matchOne(repTgDO);
if(repEmailDto!=null){
return repEmailDto;
}
}
throw new PrivateException(SysErrorCodeEnum.XBL0300,"报告《"+repTgDO.getRtbgmc()+"》无法匹配到邮件规则");
}
如上,在编写单元测试时,每个规则独立编写单元测试是很容易的;
如下,同样需求的功,虽然通过拆分私有方法打成单一职责原则,但是违反了开闭原则,一旦需求变更就对该方法做修改。同事耦合的功能在编写单元测试时,需要覆盖和考虑更多的分支,会另方法的测试变困难,代码以及单元测试都变得不稳定
public TemReplyDO matchOne(String tzzhbh,String glrmc,String bglx,String cpfldm){
TemReplyDO matchTem;
if(StringUtils.isNotEmpty(tzzhbh)){
//1.报告类型+投资组合
matchTem = matchOneTemplateByProduct(tzzhbh,bglx);
if(matchTem!=null){
return matchTem;
}
}
if(StringUtils.isNotEmpty(glrmc)){
//2.管理人+报告类型 + 产品分类
matchTem = matchOneTemplateByMagAndProductType(bglx,cpfldm,glrmc);
if(matchTem!=null){
return matchTem;
}
}
//3.报告类型+产品分类代码
matchTem = matchOneTemplateByProductType(bglx,cpfldm);
if(matchTem==null){
throw new PrivateException(SysErrorCodeEnum.XBL0100, "操作失败,无法匹配到模板");
}
return matchTem;
}