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