• 9
  • 9
分享

如何正确编写单元测试?

国内的大多数互联网公司只注重软件功能,却往往忽略了极为重要的软件质量,在一个月以前,我认为遵循了代码规范(阿里规约、sonar)的软件系统已经算是一个质量比较好的软件系统了,但是在我了解单元测试以后,才发现自己以前的想法有多么愚蠢,单元测试的作用远比我想象的要重要许多。经过一段时间的研究,总算对单元测试有了一个大概的了解,然而网上的文章零零散散,大多是讲解一些比较简单的demo,参考价值比较有限,因此我决定写一篇关于单元测试的文章来总结自己这段时间的收获与心得。

背景

软件系统刚开发完成时几乎不会出现Bug。为什么呢?因为刚开发第一版软件系统时,需求并不复杂,场景也不是很多,因此实现起来比较简单,再加上测试小哥哥/小姐姐保驾护航,基本不会出现比较严重的bug。但是随着时间的推移,系统功能越加越多,需求越来越复杂,既要兼容原来的功能完好无损、又要保证新增的功能正常使用,再加上项目工期的不断逼近,导致开发小哥压力山大,于是心理历程逐渐转变为:代码和人只要有一个能跑就行的诡异心理。如果产品需求再模糊不清、频繁修改,估计开发小哥想死的心都有了 。与此同时,测试小哥也同样不轻松,因为他发现每次发布新功能竟然有可能会影响到另一个毫不相关的功能,为了保证每次发布新功能时不影响原有功能,于是不得不将原有功能进行回归测试,这无疑给测试小哥增加了成倍的工作量,久而久之,这个系统被越来越多的人厌烦,最后当大家都不愿意再维护这个系统时,这个系统也就走到了终点。即使最后想要重构,也会感觉无从下手,因为你无法预估代码变更所带来的的风险。

测试金字塔

针对上述问题,业界有一套公认的指导方案——测试金字塔。它将测试步骤分为多个层次,每个层次关注不同的测试内容,对于层次的划分,网上有很多种方式,但无一例外,它们最底层都是单元测试,由此可见,编写单元测试是多么的重要。随着对单元测试的不断了解,相关问题也随之而来:应该怎样编写单元测试?哪些代码需要编写单元测试?怎样评判单元测试的好坏?怎样规范的编写单元测试?单元测试的能够带来的好处有哪些?下面让我们一起来了解一下单元测试的爱恨情仇。

1.png

单元测试Demo

首先大致介绍一下该项目的背景,我们公司最近正在开发一个很小的功能,因为某些原因不得不拆分为一个独立项目进行开发,而我就是这个项目的开发人员,由于领导强烈要求80%的单元测试覆盖率以满足SonarQube的标准,所以我不得不花点时间去研究它。由于这个项目比较小,所以我就直接拿来当案例使用了(删除了一些敏感信息)。
项目技术栈:SpringBoot、JUnit4、mysql、Redis、mybatis-plus、Mockito
项目案例开源地址:https://gitee.com/hechaoqi123/unit-test.git

JUnit4的基础用法

JUnit是一个Java语言的单元测试框架,应用之广泛应该能够与Spring相媲美了吧。据我了解JUnit有两个广泛流传的版本,分别是JUnit4与Junit5,这两个版本的用法存在着很多差异,因此不建议混合使用,SpringBoot框架中已经默认支持了JUnit作为测试框架。因为我最先接触的是JUnit4版本,因此下文以JUnit4进行示例。

示例代码

public class DesensitizationUtil {
    public static String len11mobile(String mobile){
        String first = mobile.substring(0, 2);
        String last = mobile.substring(mobile.length()-4);
        return first+"****"+last;
    }
}

代码分析

这是一个非常简单的工具类,其功能是做手机号的脱敏处理,现在需要编写这个方法的单元测试,首先让我们分析一下单元测试的目的有哪些?

  • 我们希望单元测试可以验证这个方法的功能是否正常。

  • 我们希望单元测试可以将这个方法的所有情况全部验证,而不仅仅是某一个特定的条件

  • 当我们需要更改这个方法的实现细节时,单元测试可以帮助我们验证这次变更是否正确。

针对以上几点,我编写了如下的单元测试

单元测试

public class DesensitizationUtilTest {
    @Test
    public void testLen11mobile() {
        String mobile = "123456789";
        Assert.assertEquals(DesensitizationUtil.len11mobile(mobile),"12****6789");
    }
}
  • 当len11mobile()方法发生变化而被破坏时,该测试用例可以检测出其返回结果与期望值不匹配,从而进行风险提示

  • 上述例子只存在一个条件分支,因此只需要编写这一个测试用例就可以完全覆盖len11mobile()方法了。

  • 当我们需要修改此方法的内部实现时,如果该测试用例通过,则说明本次变更没有更改此方法的行为,因此便不会导致其他功能受其影响。在系统重构时,这一点尤为重要

Mockito的基础用法

上述例子仅仅完成了一个及其普通的单元测试,但是我们大多数的业务场景往往不那么简单,我们可能需要查询数据库、可能需要调用三方接口、也可能需要依赖其他组件(redis、mq)等等。这个时候我们面临的第一个问题就出来了:如何在单元测试中屏蔽掉这些外来因素的影响?于是Mockito被引入进来,使用Mockito,我们可以模拟一些对象的行为使其返回特定的数据。再说白一点就是Mockito会在运行单元测试时生成指定对象的代理对象,从而跳过真实的业务逻辑并返回我们预先设定好的数据类型(如果不理解的话建议先动手写个Demo,相信你会有更深刻的理解)。

示例代码

@AllArgsConstructor
public class UserServiceImpl implements UserService {
 
    private final UserMasterMapper userMasterMapper;
    
    @Override
    public boolean markMerchant(MarkMerchantModel model) {
        // 查询用户信息
        UserMasterEntity user = userMasterMapper.selectOne(
                Wrappers.<UserMasterEntity>lambdaQuery().eq(UserMasterEntity::getUserId,model.getUserId()));
        // 校验用户是否存在
        ExceptionAssertEnum.USER_NOT_EXIST.notNull(user);
        // 将其标记为商家类型
        user.setIsShopMerchant(model.isMarkMerchant());
        user.setShopMerchantDate(LocalDateTime.now());
        // 更新数据库
        int count = userMasterMapper.updateById(user);
        // 检验是否更新成功
        ExceptionAssertEnum.SYSTEM_EXCEPTION.isTrue(count == 1);
        // 返回业务结果
        return true;
    }
}

代码分析

这是一个较为简单的业务方法,该方法的功能是将用户标记为商家类型,为了使大家看起来更方便一些,我将每行代码都加了注释,大家可以看到这个方法其实存在多种不同的行为:

  • 当业务执行成功时返回true

  • 当数据库查询不到用户信息时抛出:USER_NOT_EXIST异常

  • 当数据库写入失败时抛出:SYSTEM_EXCEPTION异常

以上的几种行为便是单元测试所需要验证的内容,然而这些行为的验证都离不开DB的支持,因此我们需要通过Mock跳过DB操作,于是编写了如下的单元测试

单元测试

public abstract class BaseTest {
    @Before
    public void before() {
        MockitoAnnotations.openMocks(this);
    }
    public void assertThrows(ThrowingRunnable runnable, Integer errorCode) {
        BusinessException e = Assert.assertThrows(BusinessException.class, runnable);
        Assert.assertEquals(errorCode,e.getCode());
    }
}
 
public class UserServiceImplTest extends BaseTest {
 
    @Mock
    private UserMasterMapper userMasterMapper;
    @InjectMocks
    private UserServiceImpl service;
 
    @Test 
    public void testMarkMerchant() {
        MarkMerchantModel model = new MarkMerchantModel();
        model.setUserId("RealUserId");
        model.setMarkMerchant(true);
        when(userMasterMapper.selectOne(any())).thenReturn(new UserMasterEntity());
        when(userMasterMapper.updateById(any())).thenReturn(1);
        Assert.assertTrue(service.markMerchant(model));
    }
 
    @Test
    public void testMarkMerchantForUserNotExistException() {
        MarkMerchantModel model = new MarkMerchantModel();
        model.setUserId("testUserId");
        assertThrows(() -> service.markMerchant(model), ExceptionAssertEnum.USER_NOT_EXIST.getCode());
    }
 
    @Test
    public void testMarkMerchantUpdateException() {
        MarkMerchantModel model = new MarkMerchantModel();
        model.setUserId("RealUserId");
        model.setMarkMerchant(true);
        when(userMasterMapper.selectOne(any())).thenReturn(new UserMasterEntity());
        when(userMasterMapper.updateById(any())).thenReturn(0);
        assertThrows(() -> service.markMerchant(model), ExceptionAssertEnum.SYSTEM_EXCEPTION.getCode());
    }
 }
根据方法名称我想大家应该也可以猜得到这三个测试用例分别是对应以上三种行为。这里继承了BaseTest,因为我喜欢在父类中编写一些公共的方法。而@Before标注的方法会重复执行在每一个测试用例之前,MockitoAnnotations.openMocks(this)方法代表开启Mockito的注解功能,@Mock注解可以生成一个UserMasterMapper的代理对象,@InjectMocks注解可以将@Mock生成代理对象注入到serivce中,最后在具体的测试用例中通过when()设置不同的返回数据,从而完成UserMasterMapper对象的模拟,然后通过Assert验证该方法的行为是否符合预期,从而决定了单元测试的成功与否。

Mockito的用法其实还有很多,我没有一一叙述,因为相对于基础教学之类的文章,我更喜欢写一些能够传递我的思想观点的文章。

针对单元测试产生的疑问?

单元测试的目的?

代码变更时保证软件系统原有功能不被破坏。

单元测试的粒度?

我认为单元测试的粒度应该精确到类中的某个具体方法。

单元测试的覆盖率?

我们之所以编写单元测试,是为了保证业务代码的可靠运行。盲目追求100%的测试覆盖率并不会给我们带来质量上的提升,反而会加重我们的负担。所以不要为了测试覆盖率而编写单元测试。

单元测试的覆盖范围?

类覆盖、方法覆盖、行覆盖、条件覆盖。我认为条件覆盖是最为苛刻的一种,因为它需要输入不同的条件进行测试

哪些代码需要单元测试?

非常简单的方法(get、set、equals…)以及不对外暴露的方法(private…)无须编写单元测试

单元测试是否需要被测方法同步更新?

单元测试只关注被测方法的行为(参数、返回值),而不应该关注其实现细节。。

单元测试是否需要依赖Spring环境?

单元测试不需要依赖Spring环境,我更愿意将需要依赖Spring特性(Aop)的单元测试理解为一种狭义的集成测试。

单元测试是否需要依赖外部系统或中间件?

每一个开发人员都需要能够在本地反复的执行单元测试,所以单元测试不建议依赖任何的外部因素,这些因素都可能导致单元测试的失败,包括mysql、nacos、seate、redis、openFeign、三方接口等。这些因素需要在单元测试阶段进行模拟(Mock)或屏蔽(disable)。

单元测试带来的好处有哪些?

  • 可以检测代码是否被破坏

  • 当代码难以阅读时,阅读单元测试可以帮助我们了解其功能

  • 当系统需要重构时,单元测试可以帮助我们验证被测方法的正确性

  • 可以减少回归测试的时间成本

  • 可以使开发人员对自己的代码更有信心

单元测试相关技术?

Junit4、Junit5:单元测试运行框架

Mockito、Wiremock:mock框架,用来模拟一些对象行为

SonarQube:代码静态扫描平台,可以通过静态扫描检查代码漏洞、代码规范、代码重复率、测试覆盖率等信息

Jacoco:用来分析测试覆盖率并生成可视化报告,SonarQube通过Jacoco生成的报告进行展示。

 

作者:敲得码黛

原文链接:https://blog.csdn.net/qq_39914581/article/details/121527383

  • 【留下美好印记】
    赞赏支持
登录 后发表评论
+ 关注

热门文章

    最新讲堂

      • 推荐阅读
      • 换一换
          •   一、前言:关于软件质量生命周期的概念  伴随互联网、大数据、云计算与人工智能技术为代表的新一代信息技术的不断发展与广泛应用,以及敏捷开发,DevOps等各种理论方法也层不出穷,传统意义的软件质量在概念和实质内容上已经发生了许多变化。笔者作为互联网行业从业者,对于这种变化也有一些察觉,在此也斗胆提出些微看法与思考,作为抛砖引玉之举,期望能够收获各位读者的批评和指正,在此就先表示感谢了。  在前一篇关于测试右移(Shift-Right Testing)的文章中,笔者对于测试右移(Shift-Right Testing)如何落地提出了一些扩展思路(下文中简称为“测试右移探索”)。在传统计算机软件...
            0 0 1127
            分享
          • 1、接口测试接口测试是测试系统组件间接口的一种测试。接口测试主要用于检测外部系统与系统之间以及内部各个子系统之间的交互点。测试的重点是要检查数据的交换,传递和控制管理过程,以及系统间的相互逻辑依赖关系等。由于如今的系统复杂度不断上升,传统的测试方法成本急剧增加且测试效率大幅下降,所以就要做接口测试。同时,接口测试相对容易实现自动化持续集成,且相对UI自动化也比较稳定,可以减少人工回归测试人力成本与时间,缩短测试周期,支持后端快速发版需求。接口持续集成是为什么能低成本高收益的根源。现在很多系统前后端架构是分离的,从安全层面来说,只依赖前端进行限制已经完全不能满足系统的安全要求(绕过前面实在太容易...
            11 11 1030
            分享
          •   1.Api文档导入  如果你的旧项目数据存储在其他软件上,那么迁移到apifox也很简单,apifox支持多种格式的接口文档的导入。  导入完毕之后,Apifox会将实体类数据自动生成一个数据结构,方便后面复用。  2.后端接口测试  成功导入后的项目API文档如图所示,接口的请求方法,url和参数 会自动填写到界面中,测试人员只需要手动修改相应的参数即可对单个接口进行测试。 对于接口测试常规涉及到的需求 1)校验接口传参是否合理(少传,漏传,多传,边界值测试和空值测试等); 2)response返回值是否符合api文档约定,数据是否存在异常,是否有做容错机制 3)接口的安全性测试等 Ap...
            0 0 1933
            分享
          •   优酷投影 Action 目前已经在京东现货开售,这款投影仪主要强调三大特性,即“终身会员”、“华为海思 V811X 处理器”,“2800 流明”,售价为 7998 元。  据介绍,这款投影仪免费赠送酷喵 TV 设备终身会员(注:仅限优酷投影仪 Action 设备使用),采取“一年领一份年卡的形式”进行赠送。  据介绍,优酷投影仪 Action 采用激光 + LED 全色域光源,支持 4K UI 显示,亮度达 2800CVIA,拥有 8 片 HOYA 高透镜片,1.53 大光圈。  该投影仪采用华为海思 V811X 处理器和 DLP.47" 4KDMD 芯片,配备 4GB RAM ...
            0 0 631
            分享
          • 前端交互测试前端页面与后端代码之间的交互测试,可以理解为接口功能测试的一个子集。测试准备 在进行交互测试前,首先要对前端功能有明确的认知,能够明确区分: 什么功能属于前端页面逻辑功能 什么功能又属于前端与后端交互功能 前端功能与后端是通过什么接口方式进行交互 前、后端,双方有什么样约束 在这里提到了约束这个概念,在实际项目研发过程中,功能测试阶段所产生 的 bug,有很大一方面是由于前、后端沟通不彻底,需求确认模糊导致。在进入研发前,双方将各自 后续由于 bug 导致的反工工作量。测试方法可以使用抓包工具...
            10 11 1508
            分享
      • 51testing软件测试圈微信