• 0
  • 0
分享
  • 如何正确编写单元测试?——软件测试圈
  • 北极 2022-10-21 13:27:04 字数 6315 阅读 2119 收藏 0

国内的大多数互联网公司只注重软件功能,却往往忽略了极为重要的软件质量,在一个月以前,我认为遵循了代码规范(阿里规约、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验证该方法的行为是否符合预期,从而决定了单元测试的成功与否。 

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

单元测试的目的?

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

单元测试的粒度?

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

单元测试的覆盖率?

我们之所以编写单元测试,是为了保证业务代码的可靠运行。盲目追求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://zhuanlan.zhihu.com/p/437398034

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

热门文章

    最新讲堂

      • 推荐阅读
      • 换一换
          • 静态测试静态测试是一种无需执行任何代码即可完成的测试。审查、演练和检查是执行静态测试的不同方法。诸如审查需求文档、客户需求规范、高级和低级设计、代码语法、命名标准等活动都属于静态测试。静态测试也适用于测试用例、测试计划、测试场景。进行静态测试是为了防止缺陷,而不是在后期捕获缺陷。这就是静态测试具有成本效益的原因。例如, Tester 正在测试一个宠物保险网站。保费计算的逻辑在需求文档中进行了描述。作为静态测试的一部分,测试人员可以查看开发人员代码进行溢价计算,并将其与需求文档进行比较,以防止与溢价计算相关的缺陷。漏洞测试该测试涉及识别软件、硬件和网络中的弱点,称为漏洞测试。在恶意程序...
            0 0 1575
            分享
          •       沐沐在性能测试过程中,主要使用的是JMeter,但是不管性能测试采用什么工具,都需要在性能测试执行过程中监控服务器资源情况,去分析性能瓶颈。本文将主要介绍一下top和htop命令。top:为linux自带的命令,能够实时监控系统给的运行状态,top命令执行后如下截图:load average:系统的运行队列的平均利用率,也可以认为是可运行进程的平均数。三个值分别表示在最后1分钟、5分钟、15分钟的平均负载值。例如在单核CPU的load average的值为1时表示满负荷状态。同理在多核CPU中满负载的load average的值是1*cpu核数。%Cp...
            2 0 3768
            分享
          • 在8月11日的雷军年度演讲上,小米公布了自动驾驶技术方面的进展,展示其自动驾驶技术算法及全场景覆盖的能力。雷军还带来了一个自己创作的新品——新书《小米创业思考》,这本书由雷军口述,而后由徐洁云进行整理,是雷军写的第一本商业方面的书,由中信出版集团股份有限公司2022年8月1日出版。小米雷军:我们先确保做一款好车,再考虑颠覆的部分在这本书中,雷军揭露了一些小米造车方面的内幕。雷军称,对小米而言,造车是大势所趋,别无选择。雷军称,必须看到几个客观事实:第一,手机行业已经进入成熟存量竞争阶段;第二,车是最大的个人消费品,智能汽车就是当下最大的风口;第三,智能汽车是智能生态不可或缺的重要环节,它与个人...
            0 0 1165
            分享
          • 前言小程序直播功能,分为使用官方自带的直播组件( live-player-plugin ,无需二次开发,开箱即用),另一种就是使用自己服务器的流,自定义直播组件(live-player、live-pusher),这里主要讲述,第一种的使用一、准备第一要了解是否满足 直播开通条件基本满足开头直播条件的功能里会有直播,然后去申请开通一下就行了创建直播间这个直播码就是主播开启直播的入口,主播扫码就可以进入基本信息点击后选择手机直播推流直播创建时需要核实身份 同时开播时间必须在12小时内 第一次开通需要人脸识别验证样式配置二、开发使用引入插件原生引入在app.jison1. 主包引入 &nb...
            13 14 2696
            分享
          •   为什么在JMeter中执行压力测试时,出现连接异常或连接重置错误?  答案:连接异常或连接重置错误通常是由于服务器在处理请求时出现问题引起的。这可能是由于服务器过载、网络故障或配置错误等原因导致的。  解决方法:  确定服务器的负载是否过高,如果是,可以考虑增加服务器资源或优化服务器端代码。  检查网络连接是否稳定,如果存在网络故障,可以尝试重启网络设备或切换网络环境。  确认JMeter的线程数、Ramp-up时间和循环次数是否合理设置,以避免对服务器造成过大压力。  检查JMeter的代理服务器设置,并确保在浏览器中正确配置代理,以便在测试期间正确转发请求。  JMeter运行压测脚本...
            0 0 917
            分享
      • 51testing软件测试圈微信