本文最后更新于:6 个月前

写单元测试并不难,也不需要太多技巧,相反,写出可测试的代码反倒是件非常有挑战的事情。所以,下面就再来聊一聊代码的可测试性,主要包括这样几个问题:

  • 什么是代码的可测试性?
  • 如何写出可测试的代码?
  • 有哪些常见的不好测试的代码?

常见的 Anti-Patterns

1. 未决行为

所谓的未决行为逻辑就是,代码的输出是随机或者说不确定的,比如,跟时间、随机数有关的代码。

2. 全局变量

全局变量是一种面向过程的编程风格,有种种弊端。实际上,滥用全局变量也让编写单元测试变得困难。举个例子来解释一下。

RangeLimiter 表示一个 [-5, 5] 的区间,position 初始在 0 位置,move() 函数负责移动 position。其中,position 是一个静态全局变量。RangeLimiterTest 类是为其设计的单元测试,不过,这里面存在很大的问题。

public class RangeLimiter {
  private static AtomicInteger position = new AtomicInteger(0);
  public static final int MAX_LIMIT = 5;
  public static final int MIN_LIMIT = -5;

  public boolean move(int delta) {
    int currentPos = position.addAndGet(delta);
    boolean betweenRange = (currentPos <= MAX_LIMIT) && (currentPos >= MIN_LIMIT);
    return betweenRange;
  }
}

public class RangeLimiterTest {
  public void testMove_betweenRange() {
    RangeLimiter rangeLimiter = new RangeLimiter();
    assertTrue(rangeLimiter.move(1));
    assertTrue(rangeLimiter.move(3));
    assertTrue(rangeLimiter.move(-5));
  }

  public void testMove_exceedRange() {
    RangeLimiter rangeLimiter = new RangeLimiter();
    assertFalse(rangeLimiter.move(6));
  }
}

上面的单元测试有可能会运行失败。假设单元测试框架顺序依次执行 testMove_betweenRange()testMove_exceedRange() 两个测试用例。在第一个测试用例执行完成之后,position 的值变成了 -1;再执行第二个测试用例的时候,position 变成了 5move() 函数返回 trueassertFalse 语句判定失败。所以,第二个测试用例运行失败。

当然,如果 RangeLimiter 类有暴露重设(resetposition 值的函数,我们可以在每次执行单元测试用例之前,把 position 重设为 0,这样就能解决刚刚的问题。

不过,每个单元测试框架执行单元测试用例的方式可能是不同的。有的是顺序执行,有的是并发执行。对于并发执行的情况,即便我们每次都把 position 重设为 0,也并不奏效。如果两个测试用例并发执行,第 16、17、18、23 这四行代码可能会交叉执行,影响到 move() 函数的执行结果。

3. 静态方法

前面我们也提到,静态方法跟全局变量一样,也是一种面向过程的编程思维。在代码中调用静态方法,有时候会导致代码不易测试。主要原因是静态方法也很难 mock。但是,这个要分情况来看。只有在这个静态方法执行耗时太长、依赖外部资源、逻辑复杂、行为未决等情况下,我们才需要在单元测试中 mock 这个静态方法。除此之外,如果只是类似 Math.abs() 这样的简单静态方法,并不会影响代码的可测试性,因为本身并不需要 mock

4. 复杂继承

相比组合关系,继承关系的代码结构更加耦合、不灵活,更加不易扩展、不易维护。实际上,继承关系也更加难测试。这也印证了代码的可测试性跟代码质量的相关性。

如果父类需要 mock 某个依赖对象才能进行单元测试,那所有的子类、子类的子类……在编写单元测试的时候,都要 mock 这个依赖对象。对于层次很深(在继承关系类图中表现为纵向深度)、结构复杂(在继承关系类图中表现为横向广度)的继承关系,越底层的子类要 mock 的对象可能就会越多,这样就会导致,底层子类在写单元测试的时候,要一个一个 mock 很多依赖对象,而且还需要查看父类代码,去了解该如何 mock 这些依赖对象。

如果我们利用组合而非继承来组织类之间的关系,类之间的结构层次比较扁平,在编写单元测试的时候,只需要 mock 类所组合依赖的对象即可。

5. 高耦合代码

如果一个类职责很重,需要依赖十几个外部对象才能完成工作,代码高度耦合,那我们在编写单元测试的时候,可能需要 mock 这十几个依赖的对象。不管是从代码设计的角度来说,还是从编写单元测试的角度来说,这都是不合理的。

重点回顾

1. 什么是代码的可测试性?

粗略地讲,所谓代码的可测试性,就是针对代码编写单元测试的难易程度。对于一段代码,如果很难为其编写单元测试,或者单元测试写起来很费劲,需要依靠单元测试框架中很高级的特性,那往往就意味着代码设计得不够合理,代码的可测试性不好。

2. 编写可测试性代码的最有效手段

依赖注入是编写可测试性代码的最有效手段。通过依赖注入,我们在编写单元测试的时候,可以通过 mock 的方法解依赖外部服务,这也是我们在编写单元测试的过程中最有技术挑战的地方。

3. 常见的 Anti-Patterns

常见的测试不友好的代码有下面这 5 种:

  • 代码中包含未决行为逻辑
  • 滥用可变全局变量
  • 滥用静态方法
  • 使用复杂的继承关系
  • 高度耦合的代码

参考:理论三:什么是代码的可测试性?如何写出可测试性好的代码?



欢迎关注我的公众号😘