开发必备之单元测试

祸乱生于疏忽 单元测试先于交付。穿越暂时黑暗的时光隧道,才能迎来系统的曙光。

单元测试的相关介绍

​ 计算机世界里的软件产品通常是由模块组合而成的 模块又可以分成诸多子模块。 比如淘宝系统由搜索模块、商品模块、交易模块等组成,而交易模块又分成下单模块、 支付模块、发货模块等子模块,如此细分下去,最终的子模块是由不可再分的程序单 元组成的。对这些程序单元的测试,即称为单元测试(Unit Testing ,简称单测)。单元的粒度要根据实际情况判定,可能是类、方法等,在面向对象编程中,通常认为最小单元就是方法。单元测试的目的是在集成测试和功能测试之前对软件中的可测试单 元进 逐一检查和验证。单元测试是程序功能的基本保障,是软件产品上线非常重要的环。

​ 虽然单元测试的概念众所周知,但是能够深入理解的人却屈指可数 道的工程师更是凤毛麟角。在很多人看来,单元测试是一件功不在当下的事情,快速完成业务功能开发才是王道,特别是在评估工作量的时候,如果开发工程师说需要额外时间来写单测,并因此延长项目工期,估计有些项目经理就接捺不住了。其实单元测试是一件有情怀、有技术素养、有长远收益的工作,它是保证软件质量和效率的重要手段之一。单元测试的好处包括但不限于以下几点:

  1. 提升软件质量

    ​ 优质的单元测试可以保障开发质量和程序的鲁棒性。在大多数互联网企业中 开发工程师在研发过程中都会频繁地执行测试用例,运行失败的单测能帮助我们快速 排查和定位问题 使问题在被带到线上之前完成修复。正如软件工程界的一条金科玉 律一 越早发现的缺陷,其修复成本越低。一流的测试能发现未发生的故障;二流的 测试能快速定位故障的发生点 三流的测试则疲于奔命,一直跟在故障后面进行功能 回归。

  2. 促进代码优化

    ​ 单元测试是由开发工程师编写和维 这会促使开发工程师不断重新审视自己 的代码 白盒地去思考代码逻辑 更好地对代码进行设计,甚至想方设法地优化测试用例的执行效率。这个过程会促使我们不断地优化自己的代码,有时候这种优化的冲 动是潜意识的。

  3. 提升研发效率

    ​ 编写单测表面上占用了项目研发时间 但磨刀不误砍柴工 在后续的联调、集成、 回归 试阶段 单元测试覆盖率高的代码通常缺陷少、问题易修复 有助于提升项目的整体研发效率。

  4. 增加重构自信

    ​ 代码重构往往是牵一发而动全身的。当修改底层数据结构时,上层服务经常会受到影响。有时候只是简单地修改一个字段就会引起 系列错误。但是在有单元 测试保障的前提下 重构代码时我们会很 然地多一分勇气,看到单元测试 100 行通过的刹那充满自信和成就感。

​ 单元测试的好处不言而喻,同时我们也要摒 诸如单元 试是测试 员的工作 单元测试代码不需要维护等常见误解。对于开发工程师来说 编写并维护单元测试不 仅仅为了保证代码的正确性 更是一种基本素养的体现。

单元测试的基本原则

​ 宏观上,单元测试要符合 AIR 原则;微观上 单元测试的代码层面要符合 BCDE 原则。

​ AIR 即空气 单元测试亦是如此。当业务代码在线上运行时 可能感觉不到测试用例的存在和价值,但在代码质 的保障上,却是非常关键的。新增代码应该同步新增测试用例,修改代码逻辑时也应该同步测试用例成功执行。 AIR 具体包括 :

  • A : Automatic (自动化)
  • I : Independent (独立性)
  • R : Repeatable (可重复)

​ 单元测试应该是全自动执行的。测试用例通常会被频繁地触发执行 执行过程必须完全自动化才有意义 如果单元测试的输出结果需要人工介入检查,那么它一定是不合格的。单元测试中不允许使用 System.out 来进行人工验证,而必须使用断言来验证。

​ 为了保证单元测试稳定可靠且便于维护,需要保证其独立性。用例之间不允许互相调用,也不允许出现执行次序的先后依赖。如下警示代码所示,testMethod2 需要调用 testMethod1。在执行 testMethod2 时会重复执行验证testMethod1,导致运行效率降低。更严重的是,testMethod1的验证失败会影响 testMethod2 的执行。

@Test
public void testMethod1() {
    ```
}

@Test
public void testMethod2 () {
    testMethod1();
    ```
}

​ 在主流测试框架中, JUnit 的用例执行顺序是无序的,而 TestNG 支持测试用例的顺序执行(默认测试类内部各测试用例是按字典序升序执行的,也可以通过XML或注解 priority 的方式来配置执行顺序)。

​ 单元测试是可以重复执行的,不能受到外界环境的影响。比如,单元测试通常会被放到持续集成中,每次有代码提交时单元测试都会被触发执行。如果单测对外部环境(网络、服务、中间件等)有依赖 ,则容易导致持续集成机制的不可用。 编写单元测试时要保证测试粒度足够小,这样有助于精确定位问题,单元测试 用例默认是方法级别的。单测不负责检查跨类或者跨系统的交互逻辑,那是集成测试需要覆盖的范围。编写单元测试用例时,为了保证被测模块的交付质量,需要符合BCDE原则:

  • B: Border,边界值测试,包括循环边界、特殊取值、特殊时间点、数据顺序等。
  • C: Correct,正确的输入,并得到预期的结果。
  • D: Design,与设计文档相结合,来编写单元测试。
  • E : Error,单元测试的目标是证明程序有错,而不是程序无错。为了发现代代码中潜在的错误 我们需要在编写测试用例时有一些强制的错误输入(如非法数据、异常流程、非业务允许输入等)来得到预期的错误结果。 由于单元测试只是系统集成测试前的小模块测试,有些因素往往是不具备的,因 此需要进行Mock,例如:
    1. 功能因素。 比如被测试方法内部调用的功能不可用。
    2. 时间因素。 比如双十一还没有到来,与此时间相关的功能点。
    3. 环境因素。 政策环境,如支付宝政策类新功能,多端环境 PC 、手机等。
    4. 数据因素。 线下数据样本过小,难以覆盖各种线上真实场景。
    5. 其他因素。 为了简化测试编写,开发者也可以将某些复杂的依赖采用 Mock 方式实现

​ 最简单的 Mock 方式是硬编码,更为优雅的方式是使用配置文件,最佳的方式是使用相应的 Mock 框架,例如 JMockit、EasyMock、JMock 等。 Mock 的本质是让我们写出更加稳定的单元测试 隔离上述因素对单元测试的影响 使结果变得可预测,做到真正的“单元”测试。

单元测试的编写

单元测试编写是开发工程师的日常工作之一,利用好各种测试框架并掌握好单元测试编写技巧,往往可以达到事半功倍的效果。本节主要介绍如何编写 JUnit 测试用例。 我们先简要了解一下 JUnit 单元测试框架。

JUnit 单元测试框架

​ Java 语言的单元测试框架相对统一,JUnit和TestNG 几乎始终处于市场前两位。 其中 JUnit 以较长的发展历史和源源不断的功能演进,得到了大多数用户的青睐,也是阿里内部目前使用最多的单元测试框架。 JUnit项目的起源可以追溯到 1997 年。两位参加“面向对象程序系统语言 和应用大会”( Conference for Object-Oriented Programming Systems, Languages & Applications )的极客开发者 Kent Beck和Erich Gamma 在从瑞士苏黎世飞往美国亚特兰大的飞机上,为了打发长途飞行的无聊时间,他们聊起了对当时 Java 测试过程中缺乏成熟工具的无奈,然后决定一起设计一款更好用的测试框架,于是采用结对编程的方式在飞机上完成了 JUnit 雏形,以及世界上第一个 JUnit单元测试用例。经过 20 余年的发展和几次重大版本的跃迁, JUnit 2017 月正式发布了 5.0 定版本。 JUnit5对JDK8 及以上版本有了更好的支持(如增加了对Lambda 表达式的支持), 并且加入了更多的测试形式,如重复测试、参数化测试等。因此本书的测试用例会使 JUnit5 采编写,部分写法如果在 JUnit4 中不兼容,则会提前说明。

JUnit5.x 由以下三个主要模块组成:

  • JUnit Platform: 用于在 JVM 上启动测试框架,统一命令行、 Gradle和Maven等方式执行测试的入口
  • JUnit Jupiter:包含 JUnit5.x 全新的编程模型和扩展机制。
  • JUnit Vintage:用于在新的框架中兼容运行 JUnit3.x和JUnit4.x的测试用例。 为了便于开发者将注意力放在测试编写上,即不必关心测试的执行流程和结果展示,JUnit 提供了一些辅助测试的注解,常用的测试注解说明如下表所示:
  1. 注解 释义
    @Test 注明一个方法是测试方法, JUnit 框架会在测试阶段自动找出所有使用该注解标明的测试方法并运行。需要注意的是,在 JUnit5 版本中,取消了该注解的 timout参数的支持
    @TestFactory 注明一 方法是基于数据驱动的动态测试数据源
    @ParameterizedTest 注明一个方法是测试方法,这一点同@Test注解的作用一样。此外,该注解还可以让一个测试方法使用不同的入参运行多次
    @RepeatedTest 从字面意思就可以看出,这个注释可以让测试方法自定义重复运行次数
    @BeforeEach 与JUnit4 中的@Before类似 ,可以在每一个测试方法运行前,都运行一个指定的方法,在JUnit5 中, 除了运行@Test注解的方法,还额外支持运行@ParameterizedTest 和@RepeatedTest注解的方法
    @AfterEach 与JUnit4 中的@After类似 ,可以在每一个测试方法运行后,都运行一个指定的方法,在JUnit5 中, 除了运行@Test注解的方法,还额外支持运行@ParameterizedTest 和@RepeatedTest注解的方法
    @BeforeAll 与JUnit4 中的@BeforeClass 类似,可以在每一个测试类运行前,都运行一个指定的方法
    @AfterAll 与JUnit4 中的@AfterClass 类似,可以在每一个测试类运行后,都运行一个指定的方法
    @Disabled 与JUnit4 中的@Ignore类似,注明某个测试的类或方法不再运行
    @Nested 为测试添加嵌套层级,以便组织用例结构
    @Tag 为测试类或方法添加标签,以便有选择性地执行

下面是个典型的 JUnit5 测试类结构:

// 定义一个测试类并指定用例在测试报告中展示名称
@DisplayName("售票器类型测试")
public class TicketSellerTest {
    // 定义一个待测类的实例
    private TicketSeller ticketSeller;

    /**
     * 定义在整个测试类开始前执行的操作
     * 通常包括全局和外部资源(包括测试桩)的创建和初始化
     */
    @BeforeAll
    public static void init() {
        // doSomeThing...
    }

    /**
     * 定义在整个测试类完成后执行的操作
     * 通常包括全局和外部资源的释放或销毁
     */
    @AfterAll
    public static void cleanup() {
        // doSomeThing...
    }

    /**
     * 定义在每个测试用例开始前执行的操作
     * 通常包括基础数据和运行环境的准备
     */
    @BeforeEach
    public void create() {
        this.ticketSeller = new TicketSeller();
        // doSomeThing...
    }

    /**
     * 定义在每个测试用例完成后执行的操作
     * 通常包括运行环境的清理
     */
    @AfterEach
    public void destroy() {
        // doSomeThing...
    }

    /**
     * 测试用例,当车票售出后余票应减少
     */
    @Test
    @DisplayName("售票后余票应减少")
    public void shouldReduceInventoryWhenTicketSoldOut() {
        ticketSeller.setInventory(10);
        ticketSeller.sell(1);
        assertThat(ticketSeller.getInventory()).isEqualTo(9);
    }

    /**
     * 测试用例,当余票不足时应该报错
     */
    @Test
    @DisplayName("余票不足应报错")
    public void shouldThrowExceptionWhenNoEnoughInventory() {
        ticketSeller.setInventory(0);
        assertThatExceptionOfType(TicketException.class)
                .isThrownBy(() -> ticketSeller.sell(1))
                .withMessageContaining("all ticket sold out")
                .withNoCause();
    }

    /**
     * Disabled注解将禁用测试用例
     * 该测试用例会出现在最终的报告中,但不会被执行
     */
    @Disabled
    @Test
    @DisplayName("有退票时余票应增加")
    public void shouldIncreaseInventoryWhenTicketRefund() {
        ticketSeller.setInventory(10);
        ticketSeller.refund(1);
        assertThat(ticketSeller.getInventory()).isEqualTo(11);
    }

}

若是使用SpringBoot基于JUnit进行单元测试时,需要注意JUnit4和JUnit5的差异,如下:

在JUnit4中:

@RunWith(SpringRunner.class)
@SpringBootTest
public class ApplicationTests {
    @Test
    public void contextLoads() {
    }
}

在JUnit5中:

@ExtendWith(SpringExtension.class)
@SpringBootTest
public class ApplicationTests {
    @Test
    public void contextLoads() {
    }
}

断言与假设

​ 当定义好了需要运行的测试方法后,下一步则是关注测试方法的细节处理, 这就离不开断言(assert )和假设( assume):断言封装好了常用的判断逻辑 ,当不满足条件时,该测试用例会被认定为测试失败,假设与断言类似,只不过当条件不满足时,测试会直接退出而不是认定为测试失败,最终记录的状态是跳过。断言和假设是单元测试中最重要的部分,各种单元测试框架均提供了丰富的方法。以 JUnit 为例,它提供了一系列经典的断言和假设方法。

方法 释义
fail 断言测试失败
assertTrue/assertFalse 断言条件为真或为假
assertNull/assertNotNull 断言指定值为NULL或非NULL
assertEquals/assertNotEquals 断言指定两个值相等或者不相等,对于基本数据类型,使用值比较;对于对象,使用equals方法对比
assertArrayEquals 断言数组元素全部相等
assertSame/assertNotSame 断言指定两个对象是否为同一个对象
assertThrows/assertDoesNotThrows 断言是否抛出了一个特定类型的异常
assertTimeout/assertTimeoutPreemptively 断言是否执行超时,区别在于测试程序是否在同一个线程内执行
assertIterableEquals 断言迭代器中的元素全部相等
assertLinesMatch 断言字符串列表元素是否全部正则匹配
assertAll 断言多个条件同时满足

相较于断言,假设提供的静态方法更加简单,被封装在 org.junit.jupiter.api. Assumptions 类, 同样为静态方法,如下表所示:

方法 释义
assumeTrue
assumeFalse
先判断给定的条件为真或假,再决定是否继续接下来的测试

​ 相对于假设,断言更为重要。这些断言方法中的大多数从 JUnit 的早期版本就已经存在,并且在最新的 JUnit5 版本中依然保持着很好的兼容性。当断言中指定的条件不满足时,测试用例就会被标记为失败。

​ 对于断言的选择,优先采用更精确的断言,因为它们通常提供了更友好的结果输出格式(包括预期值和实际值),例如 assetEquas(100, result) 语句优于 assertTrue(100 == result)语旬。对于非相等情况的判定,比如大于、小于或者更复杂的情况 可以使用 assertTrue、assertFalse 表达,例如 ssertTrue (result > 0)。对于特别复杂的条件判定,直接使用任何一种断言方法都不容易表达时,则可以使用 Java 语句自行构造条件,然后在不符合预期的情况下直接使用 fail 断言方法将测试标记为失败。另外值得强调的是,对于所有两参数的断言方法,例如 assertEquals、assertSame 第一个参数是预期的结果值,第二个参数才是实际的结果值。例如:

assertEquals(0, transactioMaker.increase(10).reduce(10)) ,假如测试结果错误,将会在测试报告中产生如下内容:

org.opentest4j.AssertionFailedError: 
Expected : 0 
Actual : 20 

倘若将参数的位置写反,则生成报告的预期值与实际值位置也会颠倒,从而给阅读者带来困扰。

assertTimeout和assertTimoutPreemptively 断言的差异在于,前者会在操作超时后继续执行,并在最终的测试报告中记录操作的实际执行时间;后者在到达指定时间后立即结束,在最终的报告中只体现出操作超时,但不包含实际执行的耗时。

例如 使用 assertTimeout 断言的错误报告:

org.opentest4j.AssertionFailedError: execution exceeded timeout of 1000 ms by 5003 ms

使用 assertTime utPre mp ivel 断言的错误报告:

org.opentest4j.AssertionFailedError: execution timed out after 1000 ms

​ 断言负责验证逻辑以及数据的合法性和完整性,所以有一种说法,在单元测试方法中没有断言就不是完整的测试 !而在实际开发过程中,仅使用 JUnit 的断言 往往不能满足需求,要么是被局限在 JUnit 仅有的几种断言中,对于不支持的断言就不再写额外的判断逻辑,要么花费很大的精力,对要判断的条件经过一系列改造后,再使用 JUnit 现有的断言。有没有第三种选择?答案是:有的

AssertJ 的最大特点是流式断言(Fluent Assertions),与 Build Chain 模式或 Java8 的stream&filter 写法类似。它允许一个目标对象通过各种 Fluent Assertions API的连接判断,进行多次断言,并且对 IDE 更友好。但是 AssertJ的assertThat 的处理方法和之前有些不同,它利用 Java 的泛型,同时增加了目标类型对应的 XxxxAssert类,签名为public static AbstractCharSequenceAssert<?,String> assertThat(String acutal),而 JUnit 中的public static void assertThat() 是void 返回,其中, AbstractCharSequenceAssert 是针对 String 对象的,这样不同的类型有不同断言方法,如String和Date 就有不一样的断言方法。

下面通过一个例子,来一起认识一下强大的 AssertJ。首先使用 JUnit 的经典断言实现一段测试:

/**
 * 使用Junit的断言
 */
public class JUnitSampleTest {

    @Test
    public void testUsingJUnitAssertThat() {
        // 字符串判断
        String s = "abcde";
        Assertions.assertTrue(s.startsWith("ab"));
        Assertions.assertTrue(s.endsWith("de"));
        Assertions.assertEquals(5,s.length());

        // 数字判断
        Integer i = 50;
        Assertions.assertTrue(i > 0);
        Assertions.assertTrue(i < 100);

        // 日期判断
        Date date1 = new Date();
        Date date2 = new Date(date1.getTime() + 100);
        Date date3 = new Date(date1.getTime() - 100);
        Assertions.assertTrue(date1.before(date2));
        Assertions.assertTrue(date1.after(date3));

        // List判断
        List<String> list = Arrays.asList("a", "b", "c", "d");
        Assertions.assertEquals("a",list.get(0));
        Assertions.assertEquals(4,list.size());
        Assertions.assertEquals("d",list.get(list.size() - 1));

        //Map判断
        Map<String, Object> map = new HashMap<>();
        map.put("A", 1);
        map.put("B", 2);
        map.put("C", 3);
        Set<String> set = map.keySet();
        Assertions.assertEquals(3, set.size());
        Assertions.assertTrue(set.containsAll(Arrays.asList("A","B","C")));
    }

}

下面,我们使用 AssertJ来完成同样的断言:

/**
 * 使用AssertJ断言
 */
public class AssertJSampleTest {

    @Test
    public void testUsingAssertJ() {
        // 字符串判断
        String s = "abcde";
        Assertions.assertThat(s).as("字段串判断:判断首位及长度")
                .startsWith("ab").endsWith("de").hasSize(5);

        // 数字判断
        Integer i = 50;
        Assertions.assertThat(i).as("数字判断:数字大小比较")
                .isGreaterThan(0).isLessThan(100);

        // 日期判断
        Date date1 = new Date();
        Date date2 = new Date(date1.getTime() + 100);
        Date date3 = new Date(date1.getTime() - 100);
        Assertions.assertThat(date1).as("日期判断:日期大小比较")
                .isBefore(date2).isAfter(date3);

        // List判断
        List<String> list = Arrays.asList("a", "b", "c", "d");
        Assertions.assertThat(list).as("List的判断:首尾元素及长度")
                .startsWith("a").endsWith("d").hasSize(4);

        //Map判断
        Map<String, Object> map = new HashMap<>();
        map.put("A", 1);
        map.put("B", 2);
        map.put("C", 3);
        Assertions.assertThat(map).as("Map的判断:长度及key值")
                .hasSize(3).containsKeys("A", "B", "C");
    }

}

本文代码地址:https://gitee.com/reminis_com/junit-demo