JUnit

JUnit是一个开源的Java语言的单元测试框架,专门针对Java设计,使用最广泛。JUnit是事实上的单元测试的标准框架,任何Java开发者都应当学习并使用JUnit编写单元测试。

使用JUnit编写单元测试的好处在于,我们可以非常简单地组织测试代码,并随时运行它们,JUnit就会给出成功的测试和失败的测试,还可以生成测试报告,不仅包含测试的成功率,还可以统计测试的代码覆盖率,即被测试的代码本身有多少经过了测试。对于高质量的代码来说,测试覆盖率应该在80%以上。

此外,几乎所有的IDE工具都集成了JUnit,这样我们就可以直接在IDE中编写并运行JUnit测试。JUnit目前最新版本是5,也是这里要介绍的JUnit版本。 使用JUnit很方便,选中需要单测的方法,点击右键 Generate -> Tests 弹出下图JUnit5来创建测试代码。

什么是单元测试呢?单元测试是针对最小的功能单元编写测试代码。Java程序最小的功能单元是方法,因此,对Java程序进行单元测试就是针对单个Java方法的测试。

单元测试有什么好处呢?在学习单元测试前,我们可以先了解一下测试驱动开发。

所谓测试驱动开发,是指先编写接口,紧接着编写测试。编写完测试后,我们才开始真正编写实现代码。在编写实现代码的过程中,一边写,一边测,什么时候测试全部通过了,那就表示编写的实现完成了:

    编写接口
      │
      ▼
    编写测试
      │
      ▼
┌─> 编写实现
│     │
│ N   ▼
└── 运行测试
      │ Y
      ▼
   任务完成

这就是我们通常所说的TDD。当然,这是一种理想情况。大部分情况是我们已经编写了实现代码,需要对已有的代码进行测试。

单元测试可以确保单个方法按照正确预期运行,如果修改了某个方法的代码,只需确保其对应的单元测试通过,即可认为改动正确。此外,测试代码本身就可以作为示例代码,用来演示如何调用该方法。

使用JUnit进行单元测试,我们可以使用断言(Assertion)来测试期望结果,可以方便地组织和运行测试,并方便地查看测试结果。此外,JUnit既可以直接在IDE中运行,也可以方便地集成到Maven这些自动化工具中运行。

在编写单元测试的时候,我们要遵循一定的规范:

  • 单元测试代码本身必须非常简单,能一下看明白,决不能再为测试代码编写测试,这样套娃的做法;
  • 每个单元测试应当互相独立,不依赖运行的顺序;
  • 测试时不但要覆盖常用测试用例,还要特别注意测试边界条件,例如输入为0,null,空字符串""等情况。

不要对单元测试存在如下误解:

  • 那是测试同学干的事情,实际上单元测试与开发同学强相关的;
  • 单元测试代码是多余的。系统的整体功能与各单元部件的测试正常与否是强相关的;
  • 单元测试代码不需要维护。一年半载后,那么单元测试几乎处于废弃状态;
  • 单元测试与线上故障没有辩证关系。好的单元测试能够最大限度地规避线上故障。

编写JUnit测试

我们编写了一个计算阶乘的类,它只有一个静态方法来计算阶乘:

n! = 1 × 2 × 3 × ...× n 

代码如下:

public class Factorial {
    public static long fact(long n) {
        if (n < 0) {
            throw new IllegalArgumentException();
        }
        long r = 1;
        for (long i = 1; i <= n; i++) {
            r = r * i;
        }
        return r;
    }
}
然后按照上述的方式点击右键,生成对应的测试类 FactorialTest.java :
public class FactorialTest {

    @Test
    void testFact() {
        assertEquals(1, Factorial.fact(1));
        assertEquals(2, Factorial.fact(2));
        assertEquals(6, Factorial.fact(3));
        assertEquals(3628800, Factorial.fact(10));
        assertEquals(2432902008176640000L, Factorial.fact(20));
    }

    @Disabled
    @Test
    void testNegative() {
        assertThrows(IllegalArgumentException.class, () -> {
            Factorial.fact(-1);
        });
    }
}
执行这个单元测试,在testFact中,我们给出了入参和期待的值,并断言是否成功。 除了常规的测试用例以外,可以发现上面的例子对异常情况也有测试,testNegative. 在Java程序中,异常处理是非常重要的。我们自己编写的方法,也经常抛出各种异常。对于可能抛出的异常进行测试,本身就是测试的重要环节。因此,在编写JUnit测试的时候,除了正常的输入输出,我们还要特别针对可能导致异常的情况进行测试。

我个人对于程序的理解除了完成正常的逻辑外,绝大部分时间都是在处理异常,逻辑的复杂性绝大部分都是异常带来的, 正常的逻辑可以梳理清楚,但是异常太多太多了,程序在执行过程中有可能随时产生异常,比如io异常、参数错误、超时等等,各种情况下都有可能产生异常。 我个人觉得异常应该作为一种程序结构集成在我们的代码中,因为这是客观存在的,我不太喜欢那种大统一的无差别try然后catch打印日志。 我更倾向于在执行逻辑过程中随时随地的判断异常,当然带来的坏处是编写过程很繁琐,但我个人觉得无非是多打几个字而已,换来的是心里的踏实和程序的健壮。

回到异常情况下的单元测试,JUnit提供了assertThrows()来期望捕获一个指定的异常。第二个参数Executable封装了我们要执行的会产生异常的代码。当我们执行Factorial.fact(-1)时,必定抛出IllegalArgumentException。assertThrows()在捕获到指定异常时表示通过测试,未捕获到异常,或者捕获到的异常类型不对,均表示测试失败。

经过思考,观察Factorial.fact()方法,注意到由于long型整数有范围限制,当我们传入参数21时,得到的结果是-4249290049419214848,而不是期望的51090942171709440000,因此,当传入参数大于20时,Factorial.fact()方法应当抛出ArithmeticException。而且已有的单元测试用例已经覆盖了所有的代码行和分支。 这只是一个简单的求阶乘的算法,就有很多异常情况,更何况复杂的算法和业务逻辑,所以说写一个健壮的程序是多么不容易啊,但我们还是需要向这个目标前进,不断地完善我们单元测试用例, 至少能保证做到,凡是通过我们编写的单元测试,至少保证目前为止我们认为的健壮性,如果后续发现有问题,就继续补充单元测试,完善功能。

jacoco 单元覆盖率测试

JUnit5是Java编程语言的单元测试框架。覆盖率是指代码被测试覆盖的程度,即代码中被测试覆盖的语句、分支、条件等的比例。JUnit5可以通过集成代码覆盖率工具来测量代码覆盖率。

如果已经编写了完善的单元测试用例,那么使用jacoco进行代码覆盖率统计就很容易了。

在pom.xml中加入jacoco插件

<plugin>
                <groupId>org.jacoco</groupId>
                <artifactId>jacoco-maven-plugin</artifactId>
                <version>0.8.6</version>
                <executions>
                    <execution>
                        <id>prepare-agent</id>
                        <goals>
                            <goal>prepare-agent</goal>
                        </goals>
                    </execution>
                    <execution>
                        <id>report</id>
                        <phase>test</phase>
                        <goals>
                            <goal>report</goal>
                        </goals>

                        <configuration>
                            <!--定义输出的文件夹-->
                            <outputDirectory>target/jacoco-report</outputDirectory>
                            <!--执行数据的文件-->
                            <dataFile>${project.build.directory}/jacoco.exec</dataFile>
                            <!--要从报告中排除的类文件列表,支持通配符(*和?)。如果未指定则不会排除任何内容-->
<!--                            <excludes>**/test/*.class</excludes>-->
<!--                            https://www.baeldung.com/jacoco-report-exclude-->
                            <excludes>
                                <exclude>com/example/demo/*</exclude>
                            </excludes>
                            <!--包含生成报告的文件列表,支持通配符(*和?)。如果未指定则包含所有内容-->
                            <includes>
                                <include>com/example/demo2/*</include>
                            </includes>
                            <!--HTML 报告页面中使用的页脚文本。-->
                            <footer></footer>
                            <!--生成报告的文件类型,HTML(默认)、XML、CSV-->
<!--                            <formats>HTML</formats>-->
                            <!--生成报告的编码格式,默认UTF-8-->
                            <outputEncoding>UTF-8</outputEncoding>
                            <!--抑制执行的标签-->
                            <skip></skip>
                            <!--源文件编码-->
                            <sourceEncoding>UTF-8</sourceEncoding>
                            <!--HTML报告的标题-->
                            <title>${project.name}</title>
                        </configuration>

                    </execution>
                </executions>
            </plugin>
在执行 mvn test 后在target/jacoco-report目录中自动生成 index.html 等页面相关文件。

点击 index.html 在浏览器中展示,即可看到具体的单测结果的覆盖率。

由上图可知,指令覆盖率是85%,分支覆盖率是75% 继续查看具体的覆盖情况

标红的代码行是单元测试没有覆盖的,标绿说明单元测试已经覆盖。

我们继续增加单元测试

  @Test
  void testNegative() {
      assertThrows(IllegalArgumentException.class, () -> {
          Factorial.fact(-1);
      });
  }
重新生成测试覆盖统计:

可见已经达到了100%覆盖。

引擎例子

  • 启动引擎

    public class SieEngineTestExtension implements BeforeEachCallback, Extension {
    
        private static final ExtensionContext.Namespace NAMESPACE = ExtensionContext.Namespace.create("com.sie.engine");
    
        @Override
        public void beforeEach(ExtensionContext context) throws Exception {
            startTestEngine(context);
        }
    
        public static void startTestEngine(ExtensionContext context) {
            // 启动容器的逻辑
            TestEngineStart.start();
            storeTestEngineStarted(context);
        }
    
        public static void storeTestEngineStarted(ExtensionContext context) {
            context.getStore(NAMESPACE).put("testEngineStarted", true);
        }
    
        public static boolean isTestEngineStarted(ExtensionContext context) {
            return context.getStore(NAMESPACE).get("testEngineStarted", Boolean.class);
        }
    }
    自定义一个 SieEngineTestExtension 类,该类会启动引擎,初始化测试环境。
  • 使用

    @ExtendWith(SieEngineTestExtension.class) // 执行测试前先启动引擎
    class RecordSetTest {
        @Test
        void call() {
            Meta meta = BaseContextHandler.getMeta();
            System.out.println("userId = " + meta.getUserId());
            System.out.println("tenantId = " + meta.getTenantId());
            assertNotNull(meta);
            assertNotNull(meta.get("rbac_user"));
            Object object = meta.get("rbac_user").search(new Filter(), null, null, null, null);
            System.out.println(object);
    
            System.out.println("===========测试完成===========");
        }
    }

使用 @ExtendWith(SieEngineTestExtension.class) 注解来标识该测试类启动前会先启动引擎,那么就可以在引擎中进行后续的测试。 还是在上面的代码示例,就可以测试meta相应的方法,比如 meta.get("rbac_user").search(new Filter(), null, null, null, null);

执行mvn test 生成jacoco覆盖率图,比如下图:

单元测试规范

  1. 【强制】好的单元测试必须遵守AIR原则。
    说明:单元测试在线上运行时,感觉像空气(AIR)一样并不存在,但在测试质量的保障上,却是非常关键的。好的单元测试宏观上来说,具有自动化、独立性、可重复执行的特点。
    • A:Automatic(自动化)
    • I:Independent(独立性)
    • R:Repeatable(可重复)
  2. 【强制】单元测试应该是全自动执行的,并且非交互式的。测试用例通常是被定期执行的,执行过程必须完全自动化才有意义。输出结果需要人工检查的测试不是一个好的单元测试。单元测试中不准使用System.out来进行人肉验证,必须使用assert来验证。
  3. 【强制】保持单元测试的独立性。为了保证单元测试稳定可靠且便于维护,单元测试用例之间决不能互相调用,也不能依赖执行的先后次序。
    反例:method2需要依赖method1的执行,将执行结果作为method2的输入。
  4. 【强制】单元测试是可以重复执行的,不能受到外界环境的影响。
    说明:单元测试通常会被放到持续集成中,每次有代码check in时单元测试都会被执行。如果单测对外部环境(网络、服务、中间件等)有依赖,容易导致持续集成机制的不可用。
    正例:为了不受外界环境影响,要求设计代码时就把SUT的依赖改成注入,在测试时用spring 这样的DI框架注入一个本地(内存)实现或者Mock实现。
  5. 【强制】对于单元测试,要保证测试粒度足够小,有助于精确定位问题。单测粒度至多是类级别,一般是方法级别。
    说明:只有测试粒度小才能在出错时尽快定位到出错位置。单测不负责检查跨类或者跨系统的交互逻辑,那是集成测试的领域。
  6. 【强制】核心业务、核心应用、核心模块的增量代码确保单元测试通过。
    说明:新增代码及时补充单元测试,如果新增代码影响了原有单元测试,请及时修正。
  7. 【强制】单元测试代码必须写在如下工程目录:src/test/java,不允许写在业务代码目录下。
    说明:源码构建时会跳过此目录,而单元测试框架默认是扫描此目录。
  8. 【推荐】单元测试的基本目标:语句覆盖率达到70%;核心模块的语句覆盖率和分支覆盖率都要达到100%
    说明:在工程规约的应用分层中提到的DAO层,Manager层,可重用度高的Service,都应该进行单元测试。
  9. 【推荐】编写单元测试代码遵守BCDE原则,以保证被测试模块的交付质量。
    • B:Border,边界值测试,包括循环边界、特殊取值、特殊时间点、数据顺序等。
    • C:Correct,正确的输入,并得到预期的结果。
    • D:Design,与设计文档相结合,来编写单元测试。
    • E:Error,强制错误信息输入(如:非法数据、异常流程、非业务允许输入等),并得到预期的结果。
  10. 【推荐】对于数据库相关的查询,更新,删除等操作,不能假设数据库里的数据是存在的,或者直接操作数据库把数据插入进去,请使用程序插入或者导入数据的方式来准备数据。
    反例:删除某一行数据的单元测试,在数据库中,先直接手动增加一行作为删除目标,但是这一行新增数据并不符合业务插入规则,导致测试结果异常。
  11. 【推荐】和数据库相关的单元测试,可以设定自动回滚机制,不给数据库造成脏数据。或者对单元测试产生的数据有明确的前后缀标识。
    正例:在RDC内部单元测试中,使用RDC_UNIT_TEST_的前缀标识数据。
  12. 【推荐】对于不可测的代码建议做必要的重构,使代码变得可测,避免为了达到测试要求而书写不规范测试代码。
  13. 【推荐】在设计评审阶段,开发人员需要和测试人员一起确定单元测试范围,单元测试最好覆盖所有测试用例(UC)。
  14. 【推荐】单元测试作为一种质量保障手段,不建议项目发布后补充单元测试用例,建议在项目提测前完成单元测试。
  15. 【参考】为了更方便地进行单元测试,业务代码应避免以下情况:
    • 构造方法中做的事情过多。
    • 存在过多的全局变量和静态方法。
    • 存在过多的外部依赖。
    • 存在过多的条件语句。
      说明:多层条件语句建议使用卫语句、策略模式、状态模式等方式重构。
  16. 【参考】不要对单元测试存在如下误解:
    • 那是测试同学干的事情。本文是开发手册,凡是本文内容都是与开发同学强相关的。
    • 单元测试代码是多余的。汽车的整体功能与各单元部件的测试正常与否是强相关的。
    • 单元测试代码不需要维护。一年半载后,那么单元测试几乎处于废弃状态。
    • 单元测试与线上故障没有辩证关系。好的单元测试能够最大限度地规避线上故障。

cicd