背景

最近在给一个客户做技术咨询,然后发现了客户对于单元测试的一个有意思的现象。分享出来,大家一起学习探讨一下。

现状分析

这里以java后端项目例,发现客户写的测试长下面的样子。(代码已经脱敏处理过。)

    @Autowired
    private SampleJob handler;

    @Test
    public void testStart() throws Exception {
        SampleParamVo paramVo = new SampleParamVo();
        paramVo.setStartTime("2021-03-18");
        paramVo.setEndTime("2021-03-18");
        handler.execute(paramVo);
    } 
    @Autowired
    private SampleHandler handler;

    @Test
    public void testHandler() {
        handler.doHandler(new DateTime("2021-11-26"), null);
    } 

那么这样的测试代码有什么问题呢?

  1. 别人看不懂这个测试是在做什么。首先测试的方法名没有任何意义,其次测试代码也只是调用了某个函数。
  2. 无法运行。这类测试代码运行往往需要启动其他服务或者需要一些特殊的设置。无法运行就意味着它不能成为CI跑测试的一部分。
  3. 没有断言。没有断言就无法知道测试的代码的正确性。
  4. 使用了@Autowired这样的代码,增加了测试的耦合以及编写成本。

和客户深聊了之后发现,原来客户不同的人对单元测试的理解也不一样。

  • 写这个代码的开发人员说,“这些代码是在开发完成之后做一些自测的辅助脚本。”
  • 有的开发人员说,“我们是微服务,单元测试需要调用其他服务,写起来很麻烦,而且如果其他服务不可用时,测试也跑不过。”
  • 测试人员说:“单元测试我们有的,我每天都在写测试用例,到单元测试的时候我就会把我的用例全部过一遍。”

所以我们可以发现,有的开发人员口中的单元测试其实应该属于集成测试或者E2E测试,有的开发人员完全没有写过单元测试,而测试人员理解单元测试是自己手动测试的时候用的测试用例。

那我们就先来说说什么是单元测试。

什么是单元测试?

通常在java的世界里面,单元测试就是指对一个public的方法编写检查和验证的代码。

为什么要写单元测试?

写单元测试主要有两大目的:

  1. 验证功能实现。
  2. 保护已有功能不被破坏。

当我们写完一个方法,我们如何知道自己写的方法是按期望工作的呢?这个时候就可以添加单元测试来验证我们的代码是按期望工作的。即当我们给定指定的输入,我们获得期望的输出,则我们说这个功能是符合期望的。

其次,代码不是写了就永远不变的,当需求变更时,新增需求时,修复bug时,都会修改代码,而单元测试则能保护我们已有的功能不被破坏。保护已有功能不会被自己破坏,被新人破坏,被新功能破坏。

如何写单元测试?

下面是一个单元测试的例子

    @Test
    public void should_return_fizz_given_input_can_be_divided_by_3() {
        FizzBuzz fizzBuzz = new FizzBuzz(); // Given
        String actual = fizzBuzz.sayIt(6); // When
        Assertions.assertEquals("Fizz", actual); // Then
    } 

一个标准的单元测试包含以下几个部分:

  1. 能描述清楚做了什么的测试名(方法名)
  2. 单元测试的Given、When、Then具体内容。

    1. Given:初始状态或前置条件
    2. When:行为发生
    3. Then:断言结果

写好单元测试要主要几个要点:

  • 因为测试代码并不会进入生产环境,同时我们期望测试即文档,因此测试的名称写很长也没有关系,重要的是能清晰的表达我们这个测试所覆盖的用例是什么。
  • 一个测试只测一种case。
  • 单元测试通常需要覆盖大量的case来保证我们的代码在绝大多数场景下都是按期望工作的。因此要做到这一点可以参考下面两大原则。这里就不详细讲解这两个原则,具体内容可以Google。

    • CORRECT原则
    • Right-BICEP原则
  • 单元测试有一个考核的标准就是测试覆盖率,指的是我们的代码有百分之多少被单元测试测到了。

    • 测试覆盖率分几种:行覆盖率,分支覆盖率,路径覆盖率,条件覆盖率等。每种都可以单独设置百分比。通常我们会看中行覆盖率和分支覆盖率。
    • 通常行业里面常设置测试覆盖率在85%以上。
    • 为什么不是100%?因为不是所有代码都能被测到的,比如private的构造函数是无法被测到的,这种就会降低覆盖率。
  • 通常所有的自动化测试都是开发人员来写,比如单元测试,集成测试等。

测试金字塔

说到单元测试,就不得不提测试金字塔,如下图,最底层是单元测试,最顶层是UI测试。(测试金字塔有好几种,但道理都是相通的)

看左边的箭头,越往下越快,越往上越慢,它主要包括编写越快,运行越快,定位问题越快等。

看右边的箭头,越往下成本越低,越往上成本越高,包括时间成本,金钱成本,人员成本,维护成本等。

单元测试的一些分享-LMLPHP

什么是mock?

我们在做单元测试的时候,常常可能访问外部系统或者外部类,这些外部的不可控性会让我们的单元测试成本变得很高。

常见的外部不可控性有:HTTP访问,增删文件,随机性,时间相关性,接口类等。

于是开发者便开始探索更廉价的方式来写单元测试,mock就是其中的解决方案。

mock 对象运行在本地完全可控环境内,利用 mock 对象模拟被依赖的资源,使开发者可以轻易的创建一个稳定的测试环境。

mock是Test double理论中的一种,如果对test double理论感兴趣,可以到这里了解更多,这里就不展开说了。

如何用mock?

还是以java为例,java的世界中常用的mock框架比如mockito。

下面是一个mock的例子。

    @Test
    void should_return_100_when_get_list_size() {
        List map = mock(List.class);
        //当调用list.size()方法时候,返回100
        when(map.size()).thenReturn(100);
        Assert.assertEquals(100, map.size());
    } 

单元测试是我们测试的最小单位,因此我们只测当前这个public的方法中的实现,而方法中调用第三方类的东西,我们都应该mock掉。

这样的好处有两个:

  1. 不会因为其他类的不可控性而导致这个测试方法变得难写。
  2. 其他类的修改不会导致这个测试方法挂掉。所有的变化都被隔离出去了。

什么是TDD?

最后再升华一下,简单说一说TDD,TDD的全称是Test driven development,即测试驱动开发。它是极限编程XP中的一个标准实践。

TDD要求在编写某个功能的代码之前先编写测试代码,然后只编写使测试通过的功能代码,通过测试来推动整个开发的进行。

这样做有四大好处:

  1. TDD是一个很好的契机,可以让你在考虑解决方案之前先考虑问题。
  2. 首先考虑测试会迫使你首先考虑与代码的接口。先思考接口可以帮助你将接口与实现分开。
  3. 简单设计。
  4. 几乎100%的测试覆盖率。

这里我就不详细叙述TDD相关的话题了,因为TDD是一个比较大的话题,如果感兴趣,下次专门开一个新话题来聊TDD。

TDD

04-01 05:42