假设我们有个工具类,用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,我们确信这是个正确的结果吗?面对这个问题,我们有几个选择:
- 在测试代码加入更多的断言,比如一直测试到fib(100), 甚至fib(1000),但这不但会增加测试的运行时间,而且对未测试过的n,我们还是无法确定fib(n)的返回值一定是正确的,增加更多的断言,并没有提高测试的确定性
- 在测试的中,不但检查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的使用要远远小于伦敦派。
上述基本世界观的不同,也导致这两个流派在以下两点上的不同:
- 单元测试的基本单位不同
- 对伦敦派来说,单元测试的基本单位总是一个类,伦敦派不但测试目标类的函数返回结果,还Mock和这个类交互的其他类,来确保目标类按期望的方式和相关的其他类正确交互
- 对底特律/芝加哥派来说,出发点是一个有意义的功能,为了测试这个功能,单元测试的基本单位可以是一个类,也可以是相关的几个类,底特律/芝加哥派通过检查相关类的状态来确定被测试的功能在按预期工作
- 工作方式不同
- 伦敦派遵循outside-in的风格,他们每次只关注一个用例,总是从UI开始,逐步靠近业务规则,然后连接到数据库,然后再返回到UI,然后再开始下个用例。这种方式并不要求一开始就对系统有个很清晰的设计,系统设计是在实现一个接一个的用例中逐步明确的,其潜在风险是,虽然最后能得到一个可以工作的系统,但系统的设计可能只是刚刚及格而已。这种方式的另一个好处是能持续不断地交付用户可见的功能,带来更快的反馈循环,但也存在着在不重要的细节上耗时太多,在关键任务上进展不够快的风险
- 底特律/芝加哥派遵循inside-out的风格,他们总是先从业务规则着手,之后再处理DB,最后再把系统挂接到UI。这种方式要求刚开始就对整个系统有个大体的设计,并在实现中不断完善,但这种方式也存在过度设计的风险。这种方式因为总是从最重要的业务规则开始工作,所以往往能更快地获得对业务的深度认知,从而在整体上提高项目进度,但也存在着交付用户可见的功能偏慢,反馈循环不够快的风险
尽管存在这两种不同的流派,但他们之间并未爆发大规模论战,在实践中,这两个流派往往都会大量使用对方的做法,只是有时偏伦敦派一点,有时偏底特律/芝加哥派一点。
Bob大叔推荐了一种很实用主义的做法:首先按照《Clean Architecture》的思想把系统划分为不同的组件,遵循依赖倒置原则,让低层级的组件依赖抽象程度更高的组件;然后在跨越组件边界处,采用伦敦派,确保组件间按照期望的方式进行协作;在组件内部,则成为底特律/芝加哥派,更依赖状态和属性来测试,充分保持测试的灵活性,也接受随之而来的脆弱性。
【版權聲明】
本文爲轉帖,原文鏈接如下,如有侵權,請聯繫我們,我們會及時刪除
原文鏈接:https://mp.weixin.qq.com/s/IV_RC2SBXkV1bqFk29akyQ Tag: 程序开发 程序员