討論交流
我的两分钱 2023-01-04 158 0 0 0 0
程序开发,程序员,假设我们有个工具类,用fib函数实现了斐波那契数列...publicclassMathUtil{...publicintfib(intn){...}}现在我们要对该函数进行单元测试,我们的测试代码可能…

假设我们有个工具类,用fib函数实现了斐波那契数列

...
public class MathUtil {
   ...
   public int fib(int n) {
     ...
   }
}

现在我们要对该函数进行单元测试,我们的测试代码可能是这样的:

...
public void fibTest() {
  MathUtil util = new MathUtil();
  assertEquals(0, util.fib(0));
  assertEquals(1, util.fib(1));
  assertEquals(1, util.fib(2));
  assertEquals(2, util.fib(3));
  assertEquals(3, util.fib(4));
  assertEquals(5, util.fib(5));
  assertEquals(8, util.fib(6));
  assertEquals(13, util.fib(7));
  assertEquals(21, util.fib(8));
  assertEquals(34, util.fib(9));
  ...
}

这个单元测试能让我们对fib函数有多少信心?如果fib(100)返回12345,我们确信这是个正确的结果吗?面对这个问题,我们有几个选择:

  1. 在测试代码加入更多的断言,比如一直测试到fib(100), 甚至fib(1000),但这不但会增加测试的运行时间,而且对未测试过的n,我们还是无法确定fib(n)的返回值一定是正确的,增加更多的断言,并没有提高测试的确定性
  2. 在测试的中,不但检查fib函数的返回结果,还检查fib函数的实现,通过确认其函数行为来提高函数返回正确结果的确定性。我们知道fib(n) = n if n <= 1, 否则 fib(n) = fib(n - 1) + fib(n - 2),所以除了上面直接断言函数结果的测试,我们还可以增加一个测试fib函数行为的测试,借助Mock技术,类似这样:
...
@Runwith(JMock.class)
public class MathUtilTest{
  private final Mockery context = new Mockery();
  private final MathUtil util = context.mock(MathUtil.class);
  ...
  @Test
  public void fibImplTest() {
    int n = 100;
    //设置对函数行为的检查
    context.checking(new Expectation(){{
      oneOf(util).fib(n - 1);
      oneOf(util).fib(n - 2);
    }});     
    //调用函数
    util.fib(n);
  }
}

通过对fib函数行为的检查,我们确实可以提高测试的确定性,但代价是我们的测试现在和fib函数的实现方式是耦合的,我们的测试变得有些脆弱,fib函数实现方式的改变将导致测试的失败。实际上,我们很容易就可以想到用一个n->fib(n)的缓存来保存计算过的fib(n),这样当再次用同样的n调用fib函数时,就可以直接查表返回结果了。但这样的一个实现方式的改变,却会导致上述行为测试的失败
3. 保持测试的灵活性,接受测试的不确定性。在单元测试中加入合适数量的断言,如果这些断言通过,就认为fib函数可以正确工作

上述关于测试确定性的讨论,引出了Bob大叔所说的TDD不确定原理:

确定性越高,测试越不灵活。测试越灵活,确定性越差

而对测试确定性的态度,也引出了单元测试的两个主要的流派:伦敦派 和 底特律/芝加哥派,伦敦派往往会采用上述第2种做法,而底特律/芝加哥派则可能会采取第3种做法。

伦敦派相对更注重测试的确定性,在伦敦派的世界观中,程序是由其组成部分(组件、类、函数等)的互动关系构成的,要保证一个程序能正确工作,就要对这种互动关系进行测试,在伦敦派的测试中,往往会大量使用Mock。

底特律/芝加哥派也注重测试的确定性,但如果不得不做取舍,他们重视测试的灵活性甚于确定性。在底特律/芝加哥派的世界观中,可观测的世界才对我们是有意义的,如果我们的观测符合预期,那么我们就认为程序能正确工作,在底特律/芝加哥派的测试中,Mock的使用要远远小于伦敦派。

上述基本世界观的不同,也导致这两个流派在以下两点上的不同:

  1. 单元测试的基本单位不同
  • 对伦敦派来说,单元测试的基本单位总是一个类,伦敦派不但测试目标类的函数返回结果,还Mock和这个类交互的其他类,来确保目标类按期望的方式和相关的其他类正确交互
  • 对底特律/芝加哥派来说,出发点是一个有意义的功能,为了测试这个功能,单元测试的基本单位可以是一个类,也可以是相关的几个类,底特律/芝加哥派通过检查相关类的状态来确定被测试的功能在按预期工作
  1. 工作方式不同
  • 伦敦派遵循outside-in的风格,他们每次只关注一个用例,总是从UI开始,逐步靠近业务规则,然后连接到数据库,然后再返回到UI,然后再开始下个用例。这种方式并不要求一开始就对系统有个很清晰的设计,系统设计是在实现一个接一个的用例中逐步明确的,其潜在风险是,虽然最后能得到一个可以工作的系统,但系统的设计可能只是刚刚及格而已。这种方式的另一个好处是能持续不断地交付用户可见的功能,带来更快的反馈循环,但也存在着在不重要的细节上耗时太多,在关键任务上进展不够快的风险
  • 底特律/芝加哥派遵循inside-out的风格,他们总是先从业务规则着手,之后再处理DB,最后再把系统挂接到UI。这种方式要求刚开始就对整个系统有个大体的设计,并在实现中不断完善,但这种方式也存在过度设计的风险。这种方式因为总是从最重要的业务规则开始工作,所以往往能更快地获得对业务的深度认知,从而在整体上提高项目进度,但也存在着交付用户可见的功能偏慢,反馈循环不够快的风险

尽管存在这两种不同的流派,但他们之间并未爆发大规模论战,在实践中,这两个流派往往都会大量使用对方的做法,只是有时偏伦敦派一点,有时偏底特律/芝加哥派一点。

Bob大叔推荐了一种很实用主义的做法:首先按照《Clean Architecture》的思想把系统划分为不同的组件,遵循依赖倒置原则,让低层级的组件依赖抽象程度更高的组件;然后在跨越组件边界处,采用伦敦派,确保组件间按照期望的方式进行协作;在组件内部,则成为底特律/芝加哥派,更依赖状态和属性来测试,充分保持测试的灵活性,也接受随之而来的脆弱性。


Tag: 程序开发 程序员
歡迎評論
未登錄,
請先 [ 註冊 ] or [ 登錄 ]
(一分鍾即可完成註冊!)
返回首頁     ·   返回[討論交流]   ·   返回頂部