
1.4 JUnit最佳实践
当前的软件开发人员越来越重视单元测试在软件项目中的作用,甚至还将其地位提升至与源代码同等重要的位置,因此针对不同的开发语言,业内涌现出了大量单元测试工具和框架(如表1-1所示)。
表1-1 部分语言的部分单元测试工具和框架

表1-1仅列举了针对部分开发语言的部分单元测试工具和框架,穷举所有的工具和框架并不是一件容易的事情。Java程序员最常使用的是JUnit 4.x,虽然JUnit 5自2017年就已正式发布,但是目前使用最广的单元测试工具仍然是JUnit 4.x,因此本书中有关单元测试的所有代码都是基于JUnit 4.x完成的。
本书的开端已经声明了不会详细讲解JUnit的用法,希望大家在阅读本书之前已经具备了JUnit的使用经验,这将有助于理解本书的内容。虽然本书不会专门讲解JUnit的用法,但是书中会列举很多关于JUnit的最佳实践,供大家参考。基于这些最佳实践和JUnit工具,结合1.3节中讨论过的FIRST原则,相信大家可以编写出合理且有价值的单元测试代码。下面列举JUnit最佳实践的13条建议。
1)单元测试应该尽量避免操作外部资源和数据。这一点在单元测试的可重复性原则中已经详细说明了,如果需要用到外部资源或数据,请尽量使用mock技术(比如Mockito、Powermock等,本书的第二部分将会进行详细讲解)或私有沙箱技术。
2)在软件工程进行编译打包的时候不要跳过(skip)单元测试的执行。单元测试虽然不能替代集成测试和验收测试等,但它是保证软件质量的第一道关口,因此只有在特殊情况下才可以跳过单元测试的执行,比如,在执行mvn命令时跳过单元测试的执行(“mvn clean package -DskipTests=true”)。有时我们需要通过编译的方式安装第三方软件(比如,ZooKeeper、Kafka等),为了提高编译打包的速度而跳过其单元测试方法,这种情况也是允许的,因为优秀的开源软件在发布之前,已经经历了无数次单元测试的考验,可以确保所有的功能都能正确运行。
3)不要试图在一个单元测试方法中覆盖所有的可能性。比如,程序代码1-3所示的是一个很简单的方法,用于验证一串字符串是否为合法的邮编号码。
程序代码1-3 验证邮编是否合法
public boolean isZipCode(String zipCode) { if (null == zipCode || zipCode.isEmpty()) return false; Matcher m = Pattern.compile("\\d{6}").matcher(zipCode); return m.matches(); }
对于这个简单的方法,我们首先能够想到的是,入参zipCode可能会有如下几种非法的传入值。
- zipCode的值为null。
- zipCode的值为空(“”)。
- zipCode的值不是数字。
可能会有程序员将单元测试方法写成如下的样子。
@Test public void testZipCodeWithMutipleConditions() { assertThat(isZipCode(null), equalTo(false)); assertThat(isZipCode(""), equalTo(false)); assertThat(isZipCode("sfsff"), equalTo(false)); }
请尽量避免这样做!首先,该单元测试方法并没有完全覆盖各种非法输入的情况。比如,zipCode有可能是6个空格,也有可能长度不足6位。甚至随着我们对isZipCode方法要求的提高,需要真实匹配邮政编码,比如999999虽然能够通过isZipCode方法的检测,但是在真实的世界中,这样的邮政编码很显然是不存在的。随着测试条件和用例的增多,单元测试方法也会越来越复杂,因此我们需要将不同的测试条件分散在不同的单元测试方法中,具体实现如程序代码1-4所示。
程序代码1-4 不同的单元测试方法对应于不同的测试条件
@Test public void testZipCodeInvalidNullValue() { assertThat(isZipCode(null), equalTo(false)); } @Test public void testZipCodeInvalidBlackValue() { assertThat(isZipCode(""), equalTo(false)); } @Test public void testZipCodeInvalidNaNValue() { assertThat(isZipCode("abcde"), equalTo(false)); } @Test public void testZipCodeValid() { assertThat(isZipCode("100000"), equalTo(true)); }
4)单元测试方法中必须包含assertion(断言)操作。很多程序员喜欢通过控制台输出,然后肉眼判断结果是否符合预期,请尽量不要这样做,最好是使用assertion语句而不是控制台打印输出的方式。
//请使用断言语句,而不是控制台输出,然后验证结果。 @Test public void noAssertion() { boolean isZipCode = isZipCode("100000"); System.out.println(isZipCode); }
5)单元测试方法所在的包名与源程序所在的包名应该一致。这一点很好理解,通常情况下,我们会将源代码放置在src/main/java目录中,而将测试代码放置在src/test/java目录中,但是两者的包名package应该一致。
6)不要为了提高单元测试的数量,而编写一些无意义的单元测试方法。比如,下面这样的单元测试方法。
//这样的单元测试方法是没有意义的。 @Test public void noMeaningTest() { assertThat(true,equalTo(true)); //or assertTrue(true); }
7)对于期望的异常处理不要进行刻意的捕获并断言。比如,在配置文件加载的过程中,很有可能会出现文件路径错误或者文件不存在的情况(如下所示),这就难免会出现I/O异常的问题,那么在I/O出现异常时,如何才能很好地进行捕获并且断言呢?
public Configuration loadConf(String fileName) throws IOException { //这里省略部分代码。 }
一些程序开发人员可能会将单元测试代码写成如下所示的样子。
@Test public void testLoadConf() { //故意定义一个不存在的配置文件。 final String conf = "/home/wangwenjun/app/xxx/a.xml"; boolean loadConfSuccess = true; try { loadConf(conf); } catch (IOException e) { loadConfSuccess = false; } assertThat(loadConfSuccess, equalTo(false)); }
虽然上面的单元测试方法也可以保证loadConf方法在配置文件不存在时抛出异常,并且成功捕获和断言,但是我们根本无须这样做。下面这种方式会更直接一些,因为它期望的结果并不是loadConf返回的Configuration实例,而是一个I/O异常。
//在Test注解中,传入期望的异常类型,如果该方法不抛出异常则无法通过测试。 @Test(expected = IOException.class) public void testLoadConf() throws Exception { final String conf = "/home/wangwenjun/app/xxx/a.xml"; loadConf(conf); }
8)不要在单元测试方法中捕获了异常,却什么也不做,而是仅仅输出异常堆栈信息。比如,下面列举的测试代码。
@Test public void testLoadConf() { final String conf = "/home/wangwenjun/app/xxx/a.xml"; try { loadConf(conf); } catch (IOException e) { //除了打印异常的堆栈信息之外,什么都没做,我们应该避免这种方式,具体做法请参考第7条的建议。 e.printStackTrace(); } }
9)即使是在单元测试代码下也应该激活日志(log)的功能。我们通常会在源代码的类路径(classpath)下配置用于控制日志信息的配置文件,比如log4j.properties或logback.xml等。在单元测试目录中也应该保持这样的优良习惯,也可以只开启控制台的日志输出,以观察源代码程序运行的关键信息。
10)使用自动化的构建工具。我们应当尽可能地使用构建工具,以自动化的方式执行单元测试方法,比如Maven、Sbt、Gradle等。几乎所有的构建工具都具备测试的功能,在当下的软件开发中,使用构建工具几乎已成为一种约定俗成的规范。
11)对源代码的单元测试覆盖率应该达到一定的要求。单元测试的覆盖率应满足一定的要求,以确保源代码能够充分测试,比如不低于80%,可以通过JCoCo或Sonar等工具进行统计分析。有些团队要求单元测试覆盖率达到100%,这的确有些太严格了。因为大多数时候是很难达到这样的覆盖率的,比如使用Powermock时生成的动态代理类与源代码的类根本不是同一个,对此JCoCo这样的工具在即时检测模式下是很难进行统计的。而有些时候则完全没有必要对某些方法进行单元测试,比如POJO的get和set方法。
12)保持单元测试方法简洁小巧,快速执行。单元测试方法应当秉持职责单一的原则,尽量不要在单元测试方法中做过多的事情。另外,单元测试应该对执行速度有一定的要求,甚至可以在@Test注解中增加对超时(timeout)的约束,以确保将项目工程的构建速度控制在一个既定的合理时间范围之内。
13)最重要的一条提示:单元测试应当与源代码同等重要。单元测试虽然不会被打包部署在生产环境中,支撑真实业务的运行,但是它可以在开发阶段起到确保源代码正确运行的作用。