设计之道-单元测试规范

说起单元测试,每个开发人员都很熟悉,但很多人却不重视。本人最近做了一个调查,发现很多IT公司里对于单测都没有规范,最多也只规定了一个覆盖率。很多开发人员认为单测属于可有可无,意义不大。或者有时间就写,没时间就算了的情况,甚至认为:“反正有测试同学帮忙把控代码质量,为什么还要开发浪费时间写单测呢?难道不是重复工作么?”。这个问题其实很有代表性,很多开发因为有这个想法,就算写了单测,可能也只是敷衍了事或者随意发挥,写出来的单测五花八门,没有规范可言,也就没有任何实际价值,纯粹是为了完成任务而已。

正是因为目前业内对单测没有统一的标准和要求,对于单测的意义和价值也有这样那样的疑惑,所以我想在这里和大家探讨一下我对单元测试的一些想法,或者更进一步,求同存异,拟定一个适合大多数人的单测标准供大家参考。

首先回答上面那个开发同学的问题,要回答这个问题可以从三个方面来解释:

  • 第一,职责的不同。开发人员的职责是什么?是完成一个功能的开发并保证其质量,而测试人员的职责则验收开发的成果,为产品的质量把关,保证项目交付的质量。很明显,保证代码质量是开发这一环节的工作职责之一,你自己写的代码的质量自己都不把控,又怎么能寄希望于别人能帮你把控呢?换句话说,在测试阶段发现了你代码里的很多缺陷,其实就说明了你的开发职责没有履行到位,本职工作都没有完成。
  • 第二,成本的不同。对于同一个缺陷,在越早期发现,修复的成本就越小,这个道理想必大家都知道。如果很多的质量问题,都需要到测试阶段才能发现,才来返工修复,这对整体项目的时间和资源来说绝对是不小的浪费。
  • 第三,角度的不同。对于开发人员来说,写单测更多的是针对单个方法的逻辑的测试,而对于测试人员来说,更多的是针对功能的黑盒测试,很多方法内部的逻辑其实并没有办法测到,这些逻辑是必须用单测才能覆盖到的。

回答了上面这个问题之后,我想讲讲我个人对单测的看法。单测的作用到底是什么?意义究竟如何体现?

  • 第一,单测可以很好保证代码质量,从而增加开发人员的信心,这点就不再赘述了。
  • 第二,单测可以一定程度提高代码合理性。当我们发现给一个方法写单测非常困难,比如单测需要覆盖的分支非常多,那可能说明方法可以拆分;又比如单测需要mock的调用非常多,那可能说明方法违背了单一责任原则,处理了太多的逻辑,也可以拆分等等。
  • 第三,单测能够有效防止回溯问题(regression issue)的出现。所谓回溯问题,指的就在之前版本没有在新版本才出现的问题。这种问题的严重程度是最高的,影响也是最恶劣的。原因很简单:用户可以接受一个本来在老版本就不存在的功能不可用,但是一定无法接受一个本来在老版本用地好好的功能突然失效了。在新功能开发完后,运行老功能单测,如果发现未变更逻辑的老功能单测报错,则很有可能是出现了回溯问题。
  • 第四,单测能够帮助测试人员确定回归范围。这一点其实是第三点扩展。在新功能提测的时候,开发人员需要提供测试范围,毕竟随着功能的不停增加,全量回归已经变得越来越不可能了。有些开发同学,为了安全起见,随意增加回归范围,这无疑增加了测试人员不必要的工作,是一种严重浪费测试资源的行为。在新功能开发完后,运行老功能单测,如果发现单测报错,则说明这部分老功能的逻辑可能发生了变化,单测需要进行相应的调整,且相关功能应该属于回归的范围。

接下来,我想分享一下我所总结的几条给开发人员的单测规范,具体分为三大类。

一.可衡量:单测的编写应该是可以用具体的指标衡量的

  1. 单测通过率要求100%,行覆盖率要求50%。

解释:通过率100%没啥好多说的,如果单测跑不通过,那不是单测有问题就是代码逻辑有问题。覆盖率的话可以根据具体的工程进行微调,建议不应小于40%,越底层的代码覆盖率应该越高,越新的代码覆盖率也应该越高。

  1. 老代码有逻辑变更时,单测也应该做相应的变更。

解释:这点的目的也是为了保证单测通过率100%。同时,这部分功能应该也属于改次功能的测试回归范围内。

  1. 新业务提测前,必须保证老单测的通过率也保持100%。

解释:这点的目的是为了防止回溯问题的出现。

二.独立性:单测应该是独立且相互隔离的

  1. 一个单测只测试一个方法。

解释:保证了单测的独立性。当单测出错的时候也能够明确知道是哪个方法出了问题。但这并不是说一个方法只对应一个单测,因为为了覆盖方法内的不同分支,我们可以为一个方法创建多个单测。

  1. 单测不应该依赖于别的单测。

解释:保证了单测的独立性。每个单测应该都能独立运行。不应该有A单测跑完才能跑B单测的情况。

  1. 单测如果涉及到数据变更,必须进行回滚。

解释:保证了单测的隔离性。如果单测运行后在数据库中产生了数据,那这些脏数据可能干扰测试同学的测试工作,且也可能影响别的单测的运行结果。

  1. 单测应该测试目标方法本身的逻辑,对于被测试的方法内部调用的非私有方法应进行mock,推荐使用Mockito进行mock。

解释:目标方法存在内部调用情况,进行mock可以屏蔽其他方法对目标方法的影响。这样保证了单测的独立性,一个单测只保证它测试的目标方法的逻辑正确性,而不应该受其内部调用方法的逻辑的影响,这部分应该是这些内部调用的方法对应的单测的责任。但是真实情况中,这一点是最难被严格执行,因为这样做就意味着需要对所有的方法都设计单测,比如a调用b调用c的情况,需要至少设计三个单测,而不能只对a设计单测来覆盖整个调用链。不过,这不正是单测的含义吗?对最小的逻辑单元——方法进行测试,如果对于一个调用链进行测试,更像是集成测试的范畴了。而且如果不这么做,我们就会违反上面的第4条“一个单测只测试一个方法”。只有一种情况例外,方法内部调用的是私有方法,这样的话是可以通过调用方的单测一并测试的,见下面的第13条“私有方法通过调用类的单测进行测试”。我们可以试想一种情况,当一个项目由很多人协同开发时,我怎么才能放心使用另一个人开发的方法?至少得提供单测吧,如果这个方法的测试是在其调用方的单测中的,那就没有直接对应的单测了,这样也就无法保证该方法是否被妥当测试过了。

三.规范性:单测的编写需要符合一定规范

  1. 对实现类进行测试而非接口。

解释:面向接口编程,面向实现测试。

  1. 单测应该是无状态的。

解释:即单测应该可以重复执行,且无论跑几次都应该保证通过率。比如有些方法会对当前时间进行判断,对于这类方法的单测也需根据当前时间的不同而进行不同的测试。

  1. 覆盖范围应包括所有提供了逻辑的类:service层、manager层、自定义mapper等,甚至还有部分提供业务逻辑的controller层代码。

解释:只要是提供了逻辑的就应该测试,不过个人并不建议在controller层提供业务逻辑,具体原因参考《设计之道-controller层的设计》

  1. 覆盖范围不应包括自动生成的类:如MyBatis Generator生成的Mapper类、Example类,不应包括各种POJO(DO,BO,DTO,VO...),也不应包括无业务逻辑的controller类。

解释:自动生成的类有啥好测的?POJO的getter/setter有啥好测的?没有提供业务逻辑的controller类有啥好测的?这些被排除的类应该在覆盖率统计中被剔除。

  1. 私有方法通过调用类的单测进行测试。

解释:因为私有方法在测试类内没法直接调用,除非使用反射。

  1. 单测要覆盖到正常分支和异常分支,使用专门的异常测试属性junit(expected)/testng(expectedExceptions)。禁止使用try-catch。

解释:很多同学的单测覆盖率不达标,就是因为只覆盖了正常的分支而遗漏的异常的分支。异常的测试和正常的一样重要,也就是该报错的时候就应该报错。有些同学为了达到单测的覆盖率和通过率的指标,在单测中使用try-catch,这也是不允许的,应该使用专门的异常测试注解。

  1. 如果被测试的方法的逻辑体现在方法返回或成员变量中,则使用Assert断言验证该返回或成员变量。

解释:如果一个方法的内部组装了一个返回值,或变更了一个成员变量,那么应该使用Assert来验证该返回值或成员变量是否符合预期。

比如下面的三个方法,前两个的逻辑都是体现在返回值上,后一个的逻辑体现在成员变量中。

    /**
     * 逻辑体现在返回值
     *
     * @return
     */
    public String displayName() {
        String name = "HangzhouZoo";
        return "Zhejiang " + name;
    }

    /**
     * 逻辑体现在返回值
     *
     * @return
     */
    public String luxuryShow() {
        String show = dog.run();
        return "luxury!! " + show;
    }

    /**
     * 逻辑体现在成员变量
     */
    public void close() {
        this.open = false;
    }

那么我们就可以使用Assert断言来测试这些逻辑:

    //逻辑在方法返回体现
    @Test
    public void displayName() {
        Assert.assertEquals("Zhejiang HangzhouZoo", hangzhouZoo.displayName());
    }

    //逻辑在方法返回体现
    @Test
    public void luxuryShow() {
        when(dog.run()).thenReturn("dog show");
        Assert.assertEquals("luxury!! dog show", hangzhouZoo.luxuryShow());
    }

    //逻辑在成员变量中体现
    @Test
    public void close() {
        Assert.assertTrue(hangzhouZoo.isOpen());
        hangzhouZoo.close();
        Assert.assertFalse(hangzhouZoo.isOpen());
    }
  1. 如果被测试的方法的逻辑体现在内部的方法调用行为本身,则使用Mockito的verify验证内部方法调用的情况。

解释:有些方法的内部根据不同的条件会调用不同的方法,则应该验证该方法的调用是否符合预期。Mockito的verify可以验证被mock的方法是否调用了,甚至可以验证方法调用的次数。

比如下面这个方法有三分条件分支,分支一抛出异常,分支二调用内部方法,分支三组装返回值。

    /**
     * 逻辑体现在异常、方法调用行为和返回值
     *
     */
    @Override
    public String show(Animal animal) throws ZooException {
        if (animal instanceof Tiger) {
            throw new ZooException("tiger is not allowed");
        } else if (animal instanceof Dog) {
            return animal.run();
        } else {
            return "only dogs here";
        }
    }

其中分支二的逻辑就体现在方法调用的行为上,我们可以通过verify来验证方法是否如预期一样调用,也可使用times验证方法调用的次数。

    //被测试的方法的逻辑体现在内部方法的调用行为本身
    @Test
    public void show() throws Exception {
        when(dog.run()).thenReturn("dog run");
        hangzhouZoo.show(dog);
        //验证方法被调用过了
        verify(dog).run();
        //也可以通过times参数来验证方法具体被调用的次数
        verify(dog, times(1)).run();
        //验证另一个分支,逻辑体现在返回值
        Assert.assertEquals("only dogs here", hangzhouZoo.show(new Cat()));
    }

当然,还记得第13条“异常分支也需要测试么”,我们还需要写一个单测来覆盖异常分支:

    //测试异常分支
    @Test(expected = ZooException.class)
    public void showForEx() throws Exception {
        hangzhouZoo.show(new Tiger());
    }
  1. 如果被测试的方法的逻辑体现在内部方法调用的参数中,即方法的逻辑用于构建内部调用方法的参数,则使用Mockito的verify验证内部方法调用的参数。

解释:有些方法的内部会组装一个对象,然后将这个对象作为参数传入另一个内部方法。使用Mockito的verify可以验证被mock的方法被调用的参数。如果是简单类型,可以直接验证,如果是复杂类,则需要扩展ArgumentMatcher类来做验证。

下面这个方法的逻辑体现在内部调用方法的参数构造上:

    /**
     * 逻辑体现在参数构造-基本类
     *
     * @param times
     */
    public void bark(int times) {
        int actualTimes = times * 10;
        dog.bark(actualTimes);
    }

由于参数类型是基本类,所以我们可以直接用verify来验证:

    //逻辑在参数体现-简单类型
    @Test
    public void bark() {
        doNothing().when(dog).bark(anyInt());
        hangzhouZoo.bark(3);
        verify(dog).bark(30);
        //与上面等价
        verify(dog).bark(eq(30));
    }

不过如果像下面这样的参数是复杂类的,就需要扩展一下:

     /**
     * 逻辑体现在参数构造-复杂类
     *
     * @param
     * @return
     */
    public String feedVegetable() {
        Food tomato = Food.builder().name("tomato").build();
        return dog.eat(tomato);
    }

自定义参数匹配器:

/**
 * @Author: Sawyer
 * @Description: 自定义参数匹配规则
 * @Date: Created in 2:02 PM 2019/10/15
 */

public class ObjectMatcher<T> extends ArgumentMatcher<T> {

    private Object expected;
    private Function<T, Object> getProperty;

    public ObjectMatcher(Object expected, Function<T, Object> getProperty) {
        this.expected = expected;
        this.getProperty = getProperty;
    }

    @SuppressWarnings("unchecked")
    @Override
    public boolean matches(Object actual) {
        return getProperty.apply((T) actual).equals(expected);
    }
}

测试的时候使用argThat校验方法参数:

    //逻辑在参数体现-复杂类
    @Test
    public void feedVegetable() {
        when(dog.eat(any())).thenReturn("dog eat");
        hangzhouZoo.feedVegetable();
        //验证参数
        verify(dog).eat(argThat(new ObjectMatcher<>("tomato", Food::getName)));
    }
  1. 单测应在相应的目标方法开发完后立即编写,如能在开发前就开始编写则更好(TDD)。

解释:这点可能会违背很多开发同学的认知,怎么可能先写单测再写代码呢?实际上,如果稍微了解下测试驱动开发(Test-Driven Development),就会发现这并非异想天开,反倒是顺理成章的事。我认为有两种场景下单测的习惯是很容易能够推动的,第一种是团队里没有测试人员,代码质量完全由开放人员把控;而第二种就是软件开发流程使用的是TDD的方式,这样天然的就保证了单测必须存在。

说了很多,各位开发也许认为我再为你们增加负担,其实不然,写单测是对你自己的代码负责,也就是对你自己负责,那既然要写,能有一份标准规范来指导我们写单测,让我们有据可依,有的放矢,不也是简化我们的工作么?另外,如果各位所在的公司里也制定了单测规范,不妨分享出来一起讨论讨论,感谢!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,560评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,104评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,297评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,869评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,275评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,563评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,833评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,543评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,245评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,512评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,011评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,359评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,006评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,062评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,825评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,590评论 2 273
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,501评论 2 268

推荐阅读更多精彩内容

  • 单元测试 单测定义 单元测试(Unit Testing)又称为模块测试, 是针对程序模块(软件设计的最小单位)来进...
    运维开发笔记阅读 1,952评论 0 2
  • 一、百变怪 Mockito Mockito可谓是Java世界的百变怪,使用它,可以轻易的复制出各种类型的对象,并与...
    罗力阅读 3,804评论 3 17
  • 本文试图总结编写单元测试的流程,以及自己在写单元测试时踩到的一些坑。如有遗漏,纯属必然,欢迎补充。 目录概览: 编...
    苏尚君阅读 3,383评论 0 4
  • 单元测试实践背景 测试环境定位bug时,需要测试同学协助手动发起相关业务URL请求,开发进行远程调试问题:1、远程...
    Zeng_小洲阅读 7,459评论 0 4
  • 子曰:“弟子入则孝,出则弟(tì),谨而信,泛爱众,而亲仁,行有余力,则以学文。” 孔子说:“一个孩子,在家孝顺父...
    读人阅己阅读 228评论 0 0