关于单元测试的编写实践总结

本文介绍了单元测试的概念、重要性以及实践方法,强调了单元测试在提高代码质量和维护性方面的作用。以Java为例,讲解了JUnit框架的使用,包括环境配置、断言、Mock测试,并给出了单元测试的规范和注意事项。此外,文章讨论了如何编写可读、可预测、快速的单元测试,以及如何通过重构改善不可测试的代码,提倡应用设计模式以增强代码的可测试性。
摘要由CSDN通过智能技术生成

关于单元测试的实践总结

公司开发文档中讲述

单元测试是对最小功能单元编写测试代码,以判定实际结果与期望结果是否一致。
测试驱动开发的理念提倡在具体实现代码前编写单元测试代码,在编写实现代码的过程中进行单元测试。此外,编写完实现代码后再进行单元测试也是常用的方式。长期来看,单元测试可以提高代码质量,减少维护成本。单元测试不是越多越好,而是越有效越好,单元测试应该覆盖逻辑复杂且易出错的代码、核心业务代码以及公共代码等。
这里以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次,并且结果相同,一致性

一般关注点

  1. 接口功能测试:用来保证接口功能的正确
  2. 局部数据结构测试:用来保证接口汇总的数据结构是正确的
    1. 比如变量有误初始化
    2. 变量是否溢出
  3. 边界条件测试
    1. 变量赋值:NULL
    2. 数值
      1. 溢出边界:最大值,最小值
      2. 临近边界:最大值-1,最小值+1
    3. 字符串
      1. 空字符串
      2. 长度边界
    4. 集合
      1. 空集合
      2. 集合大小边界
      3. 顺序性
  4. 所有独立执行通路测试:保证每一条代码,每一个分支都经过测试
    1. 代码覆盖率**(直接参考IDE提供的覆盖率即可)**
      1. 行覆盖率:每行代码都测试到
      2. 分支覆盖:每个分支都测试到
      3. 条件覆盖:宽限定的分支覆盖
    2. 异常路径(错误情况):保证每一个异常都经过测试(IO异常,中断异常等难以做到)

编写

在测试方法的编写中,和正常的程序编写有很大的区别,最主要的区别在于:断言,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

最基础的注解,表明该方法是一个普通测试方法,不能有返回值,不能使privatestatic

@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成员的,然后根据大家代码的不同情况会生成除测试用例,有一些规则如下:

  1. 会对所有的 public 方法都生成测试方法;
  2. 对mock的成员方法调用如果返回值是集合类型,则会生成ReturnsNoItems的测试方法,用于测试mock的方法调用返回空对象;
  3. 自动生成的测试数据,这里会直接把变量名赋值给字符串属性,需要自行根据情况调整
  4. 需要测试的类会被作为Test类的成员属性定义,根据情况生成setUp方法
  5. 多数测试方法按照 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;
}

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值