原创

咱们来聊聊单元测试与覆盖率

“Unit testing is often talked about in software development, 
and is a term that I've been familiar with during my whole time writing programs.
 Like most software development terminology, however, it's very ill-defined, 
and I see confusion can often occur when people think that it's more tightly defined than it actually is.
                                                                                  — Martin Fowler”
  因何而生
  正如Martin Fowler说的,单元测试这个词在软件开发中高频次地出现,它聚焦于系统的某一部分,通常是较低级别,比如类、方法等,是一种以较小代价换取软件“正确”的方法。这里提到了一个很重要的概念,即怎么样理解软件“正确”?
  在我看来软件的正确性包含以下几个点:
  流程符合预期,即按照设计的步骤运行,并在关键的步骤执行正确的功能,包含正确的参数;
  执行效果符合预期,即通过功能执行后,能够产生符合设计的结果,这种结果可以是直接的,比如方法的返回值;也可以是间接的,比如方法改变了实例中可被观察到的部分;
  异常保护符合预期,功能执行过程会遭遇超越设计边界的场景,保护自身不因“越界”而失效;
  质量属性符合预期,某些功能具有质量属性,如响应时间等。
  单元测试应围绕上述理解展开,它需要且应当说明被测功能的正确,比如下面这些我们常见的单元测试写法。
  public class AppTest {
      // 验证流程符合预期的测试
      @Test
      public void should_give_tips_when_input_length_not_4() throws Exception {
          // given
          // when
          when(interactable.read()).thenReturn("231").thenReturn("1234");
          app.play();
          // then
          verify(interactable).write("Please input 4 none repeatable numbers(You have 6 times).");
      }
      // 验证执行效果符合预期的测试
      @Test
      public void should_out_buzz_when_number_is_multiples_of_five() throws Exception {
          // given
          // when
          final String result = rule.transform(5);
          // then
          assertThat(result, is("Buzz"));
      }
      // 验证异常保护符合预期的测试
      @Test(expected = Exception.class)
      public should_throw_exception_when_input_invalid() throws Exception {
          // given
          // when
          // then
      }
  }
  覆盖率或测试覆盖率是用来衡量单元测试对功能代码的测试情况,通过统计单元测试中对功能代码中行、分支、类等模拟场景数量,来量化说明测试的充分度。覆盖率的前提是存在单元测试,并且从其本意上推导,可被统计覆盖率的单元测试应当是证明了软件正确的,这是一个不能动摇的基础,否则一切就失去意义。
  覆盖率报告
  从上述分析不难看出单元测试与覆盖率的侧重点是不一样的,单元测试重点在于验证软件正确,而覆盖率重点在于描述测试的充分程度,两者不会等同起来,但在项目和团队中一个普遍的认识是“高覆盖率的代码,其功能的正确性是得到保障的”。
  误入歧途
  覆盖率在持续集成中一般会作为代码准入的标准,这种选择来源于原则“没有测试覆盖的代码是不可靠的”以及它的变化衍生。大多数项目都会设定一个覆盖率的门限值,禁止无测试的代码合入同时还要警告覆盖率的降低。通常来说这么做是合理的,持续集成中覆盖率检查以一种显性的约束来规范开发人员使用单元测试保障开发代码的正确性,并让单元测试逐渐地变成开发习惯。不得不说,覆盖率检查对单元测试的普及起了十分积极的作用。
  但最近的一些发现让我对覆盖率的认识产生了一些担忧,在走查代码的过程中发现了一些写法十分奇特的单元测试,类似下面代码:
  public class GameTest {
      @Test
      public void testVerify() throws Exception {
          // given
          // when
          new Game("1234").verify("1234");
          // then
      }
  }
  这样的代码乍看是没有什么问题的,使用了测试框架,调用了被测对象的外部接口,覆盖率报告上也有体现,一句话——完美!
  但细探一下就发现如此完美的测试代码偏偏少了最重要的东西——对预期的判断,就是我们上面提到的软件正确性的4点。这就太糟糕了,因为成片的测试根本没有办法告诉开发人员他们写的代码究竟是否正确,既然没有了对错那么单元测试的意义又何在呢?
  为何会出现上面的测试呢?在与开发人员交流后发现覆盖率在这其中起了很大的因素。当项目划定了代码准入的覆盖率门限后,在短时间内大量的代码是无法提交入库的,而项目又对功能发布有较强的deadline,在这两种因素的共同作用下,就会有人想到上述的“奇招”,这样的测试不会检验功能的正确,而会产生符合要求的高覆盖率。更糟糕的是,由于无法验证功能的正确即无法产生价值于开发人员,那么测试这件事就会受到抵制,同时测试代码也会耗散有限的迭代时间,造成对单元测试的认同更加低落,使得一些本可以逐渐落地的方法,如测试驱动开发,变成空中楼阁。
  正本清源
  现在再回头看我们之前提到的关于单元测试和覆盖率的普遍认识:“高覆盖率的代码,其功能的正确性是得到保障的”,你还认为这句话一定正确吗?
  单元测试的目的是为了以较小的代价(白盒)换取软件正确,而覆盖率的目的是在有效单元测试的基础上统计测试代码测试被测对象的充分程度。两者存在联系却不能相互替换。诚然,在保证单元测试实现其目的的情况下,上述认识才真正变得有意义,如果混淆了单元测试和覆盖率的意义,那么就会出现上面的舍本逐末,此时写再多的测试也不能证明软件的正确,只能证明你对单元测试和覆盖率的误解有多深。
正文到此结束
Loading...