Java单元测试之 单元测试规范

简介: 对于程序员是否有必要编写test case,何时编写依然存在很多争议,各种互斥的方法论(SE/AM/XP/TDD),以及不同的开发文化,但是可以确定是编写单元测试用例有助于提高编程能力。

前言&背景

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

image.png

单元测试目的

正是因为目前业内对单测没有统一的标准和要求,对于单测的意义和价值也有这样那样的疑惑,所以希望探讨一下对单元测试的一些想法,求同存异。

首先要解释一下前言中开发同学的问题:

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

然后谈谈个人对单测的看法。单测的作用到底是什么?意义究竟如何体现?

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

单元测试规范

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

单测应该是无状态的。

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

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

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

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

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

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

解释:因为私有方法在测试类内没法直接调用,除非使用反射或其他Mock框架(PowerMock, TestableMock等)。

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

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

如果被测试的方法的逻辑体现在方法返回或成员变量中,则使用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());
    }
如果被测试的方法的逻辑体现在内部的方法调用行为本身,则使用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());
    }
如果被测试的方法的逻辑体现在内部方法调用的参数中,即方法的逻辑用于构建内部调用方法的参数,则使用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);
    }

自定义参数匹配器:

/**
 * 自定义参数匹配规则
 */
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)));
    }
单测应在相应的目标方法开发完后立即编写,如能在开发前就开始编写则更好(TDD)。

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

原文: 设计之道-单元测试规范
作者:SawyerZhou
相关文章
|
21天前
|
Web App开发 前端开发 Java
《手把手教你》系列技巧篇(九)-java+ selenium自动化测试-元素定位大法之By name(详细教程)
【4月更文挑战第1天】 这篇教程介绍了如何使用Selenium Webdriver通过name属性来定位网页元素,作为系列教程的一部分,之前讲解了id定位,后续还会有其他六种定位方法。文中以百度搜索为例,详细说明了定位搜索框(name=&quot;wd&quot;)并输入关键词“北京宏哥”的步骤,包括手动操作流程、编写自动化脚本以及代码实现。此外,还提供了查看和理解Selenium源码的方法,强调了`open implementation`选项用于查看方法的具体实现。整个过程旨在帮助读者学习Selenium的元素定位,并实践自动化测试。
41 0
|
16天前
|
前端开发 Java 测试技术
《手把手教你》系列技巧篇(十二)-java+ selenium自动化测试-元素定位大法之By link text(详细教程)
【4月更文挑战第4天】本文介绍了link text在自动化测试中的应用。Link text是指网页中链接的文字描述,点击可跳转至其他页面。文章列举了8种常用的定位方法,其中着重讲解了link text定位,并通过实例展示了如何使用Java代码实现点击百度首页的“奥运奖牌榜 最新排名”链接,进入相应页面。如果link text不准确,则无法定位到元素,这说明linkText是精准匹配,而非模糊匹配。文章还提到了partial link text作为link text的模糊匹配版本,将在后续内容中介绍。
36 4
|
15天前
|
Java 测试技术
SpringBoot整合单元测试&&关于SpringBoot单元测试找不到Mapper和Service报java.lang.NullPointerException的错误
SpringBoot整合单元测试&&关于SpringBoot单元测试找不到Mapper和Service报java.lang.NullPointerException的错误
17 0
|
15天前
|
XML 前端开发 Java
《手把手教你》系列技巧篇(十四)-java+ selenium自动化测试-元素定位大法之By xpath上卷(详细教程)
【4月更文挑战第6天】按宏哥计划,本文继续介绍WebDriver关于元素定位大法,这篇介绍定位倒数二个方法:By xpath。xpath 的定位方法, 非常强大。使用这种方法几乎可以定位到页面上的任意元素。xpath 是XML Path的简称, 由于HTML文档本身就是一个标准的XML页面,所以我们可以使用Xpath 的用法来定位页面元素。XPath 是XML 和Path的缩写,主要用于xml文档中选择文档中节点。基于XML树状文档结构,XPath语言可以用在整棵树中寻找指定的节点。
43 0
|
Java 测试技术
Java 中的单元测试和集成测试策略
【4月更文挑战第19天】本文探讨了Java开发中的单元测试和集成测试。单元测试专注于单一类或方法的功能验证,使用测试框架如JUnit,强调独立性、高覆盖率和及时更新测试用例。集成测试则验证模块间交互,通过逐步集成或模拟对象来检测系统整体功能。两者相辅相成,确保软件质量和降低修复成本。
|
4天前
|
前端开发 JavaScript Java
《手把手教你》系列技巧篇(二十五)-java+ selenium自动化测试-FluentWait(详细教程)
【4月更文挑战第17天】其实今天介绍也讲解的也是一种等待的方法,有些童鞋或者小伙伴们会问宏哥,这也是一种等待方法,为什么不在上一篇文章中竹筒倒豆子一股脑的全部说完,反而又在这里单独写了一篇。那是因为这个比较重要,所以宏哥专门为她量身定制了一篇。FluentWait是Selenium中功能强大的一种等待方式,翻译成中文是流畅等待的意思。在介绍FluentWait之前,我们来讨论下为什么需要设置等待,我们前面介绍了隐式等待和显式等待。
27 3
|
6天前
|
Java 测试技术 定位技术
《手把手教你》系列技巧篇(二十三)-java+ selenium自动化测试-webdriver处理浏览器多窗口切换下卷(详细教程)
【4月更文挑战第15天】本文介绍了如何使用Selenium进行浏览器窗口切换以操作不同页面元素。首先,获取浏览器窗口句柄有两种方法:获取所有窗口句柄的集合和获取当前窗口句柄。然后,通过`switchTo().window()`方法切换到目标窗口句柄。在项目实战部分,给出了一个示例,展示了在百度首页、新闻页面和地图页面之间切换并输入文字的操作。最后,文章还探讨了在某些情况下可能出现的问题,并提供了一个简单的本地HTML页面示例来演示窗口切换的正确操作。
27 0
|
9天前
|
前端开发 JavaScript Java
《手把手教你》系列技巧篇(十九)-java+ selenium自动化测试-元素定位大法之By css下卷(详细教程)
【4月更文挑战第11天】按计划今天宏哥继续讲解css的定位元素的方法。但是今天最后一种宏哥介绍给大家,了解就可以了,因为实际中很少用。
34 2
|
10天前
|
缓存 自动驾驶 测试技术
如何进行有效的Apollo测试:单元测试和集成测试指南
如何进行有效的Apollo测试:单元测试和集成测试指南
38 13
|
11天前
|
前端开发 JavaScript Java
《手把手教你》系列技巧篇(十八)-java+ selenium自动化测试-元素定位大法之By css中卷(详细教程)
【4月更文挑战第10天】本文主要介绍了CSS定位元素的几种方法,包括ID属性值定位、其他属性值定位和使用属性值的一部分定位。作者提供了示例代码,展示了如何使用这些方法在Java+Selenium自动化测试中定位网页元素。通过CSS选择器,可以更精确地找到页面上的特定元素,如输入框、按钮等,并进行相应的操作,如输入文本、点击等。文章还提供了实际运行代码后的控制台输出和浏览器动作的示例。
47 0

热门文章

最新文章