使用 JUnit4 编写单元测试
  • 主要内容: 本文从 What, Why, When, How, Deep 几个方面来介绍单元测试相关的基础知识。
  • 适合阅读: 对单元测试不了解或者一知半解的程序员
  • 需要技能: 了解 Java 编程语言,能够使用 IDEA 等。

What

单元测试(unit test)并不同于普通的端到端测试,这是一项需要程序员通过实际编码来完成,并且与程序的设计和调试紧密相关的任务。单元的大小,并没有严格规定,但是遵照着软件设计中”SRP”(Single Responsibility Principle)原则,在 Java 中通常以类作为一个基本的测试单元,以此编写单元测试来对功能或者说行为进行监视和检测。 JUnit 是 Java 编程语言中比较流行的一款单元测试框架,能够满足平时开发中大部分的需求。

Why

编写单元测试的主要目的是为了检视代码的行为是否符合预期,并且这种检查通常是成本很低的,开发人员可以方便的在 IDE 或本地开发环境中及时的发现问题,从而提高开发效率。

When

编写合理的单元测试可以轻松应付以下的几个场景

How

在 IDEA 中使用单元测试是很方便的,可以参考官方的教程创建单元测试 接下来使用的示例代码模拟实现了一个功能,根据一个程序员的技能和等级,为一个程序员打分。详细的代码可以到 github 上查看 code-example, 推荐根据提交历史来进行查看。具体的环境如下:

注解

Junit4 开始使用的注释提高了单元测试的编写效率,在引入 Junit4依赖后,在需要进行测试的方法上添加一个@Test 注释即可。其他常用的注释还有@Before、@After、@Rule 等。在后面遇到的时候在详述

命名

良好的测试方法命名能起到见名知义的效果。理想情况下,命名中需要包含测试的方法,条件以及期望的返回结果。可以提炼成以下的格式 ·callSomeMethodReturnSomeResultWhenSomeConditions 或者其他的类似的变体。比如given-then-when等等

方法体

前面的命名中提到的三个信息也正是组织单元测试的基本依据。即

  1. 准备条件 (arrange)
  2. 执行目标方法 (act)
  3. 检验结果 (assert) Assert 这个单词的直译是断言,意思是判断某项条件是否为真。在单元测试中我们通过断言来实现结果检验的语义, 以此来监视代码的行为。 JUnit 中可以使用两种不同的断言风格— classic 和 matcher。通过代码可以直观的看出二者之间的差别。
     public class SkillGraphTest {
    	
         @Test
         public void getResultReturnZeroWhenSkillGraphEmpty() throws Exception {
             // arrange
             SkillGraph skills = new SkillGraph();
             // act and assert -- classic style
             assertTrue(skills.getResult() == 0);
             // act and assert -- matchers style
             assertThat(skills.getResult(), equalTo(0d));
         }
     }
    

    上面为 SkillGraph 类编写的单元测试示例中,我们验证的行为是当程序员的技能图中没有添加任何技能时调用 getResult 方法需要返回0(double 类型) 由于代码很简单,所以将执行和验证的两个阶段合并到一起了。

我们主要看一下不同风格的断言:

  1. classic 风格,即验证某项条件为真。在这个方法中即需要验证执行方法的结果 skills.getResult与我们期望的结果是否相等。也就是skills.getResult() == 0 
  2. matchers 风格,通过语义化的代码来验证结果是否匹配。整个代码不需要太多的逻辑思考,更贴近我们的阅读习惯 — skills.getResult() is equal to 0d 在 Junit 中,Assert 类中提供了基础的断言 API。通过 assertThat(actualResult, matchers) 方法可以利用 Hamcrest 开发的大量 Matcher 为我们的单元测试提供更好的语义化支持。 在此,我不强烈推荐其中任何一种,但是需要注意的是一个项目中的断言风格应该尽量保持一直。

测试异常

在编写单元测试时,如果测试结果不符合预期,JUnit 会报告一次failure;如果单元测试程序运行过程中如果出现了异常,则会报告一次error。由于单元测试的隔离性,我们通常将关注点集中在需要测试的功能上,而不需要浪费时间在异常处理上,因此一些受检查异常直接在方法签名上抛出即可。 有些时候我们可能会需要验证异常抛出的正确性,以此来保证客户端使用的正确性和可靠性,有三种形式可以来验证异常行为是否合理。

Deep

优化重构测试代码

在编写单元测试时,分支条件边界条件是需要重点关注的。这也会导致一个类的单元测试往往需要编写很多单元测试。因此有必要通过优化重构来保持代码的整洁和良好的可维护性和扩展性。 在单元测试中,我们在 arrange 阶段往往会执行一些对象初始化等一些初始化操作,这个过程通常是重复的。可以通过@Before 注解一个初始化方法,这样在每个单元测试之前都会执行这段代码了。类似的还有@After注解,只不过不太常用。 其实之前提到的@Rule注解实现的就是类似@Before@After的功能,在执行一个单元测试方法的前后执行一些方法,只不过这些方法都是有具体的目标,通过规则这个语义实现。

注: JUnit 中每个单元测试都拥有独立的上下文环境,执行每个测试方法时都会单独生成一个新的实例,因此无法保证单元测试用例的执行顺序,导致我们在单元测试中不能依赖其他的测试结果,当然这种需求本身就是“anti pattern”的。

JUnit 运行

一个典型的单元测试用例的执行如下

  1. 创建单元测试类实例
  2. 调用@Before 方法
  3. 执行某一个@Test注释的方法
  4. 调用@After 方法
  5. 创建新的单元测试类实例
  6. 调用@Before 方法
  7. 执行另外某一个@Test注释的方法
  8. 调用@After 方法 … …

边界条件

编写单元测试最主要、最直接的关注点就是方法执行的正确性。其次需要关注数据的边界条件,即在某些极端的或者不正确的条件下,程序能否正常的运行或者合理的运行,以此来保证系统的健壮性。常见的关注点有以下几个

  1. 不合实际的值(例:表示人类年龄的字段输入180或者负数)
  2. 不符合格式的值 (例:邮件,手机等)
  3. 算数溢出
  4. 空值,Null,空集合等
  5. 不合理的重复值
  6. 没有合理排序的值
  7. 事件发生的顺序异常(例: 在创建用户之前就进行信息编辑等)

小结

编写单元测试算是一项内功修炼,也是一项烦琐的工作。曾国藩曾说过,“成大事者,必能耐烦”。如果单纯为了提高代码覆盖率,确实很烦;如果为了改进代码结构防患 Bug 于未然,就会发现单元测试是如此有用。