如果你参加过程序员面试,你一定听过这样一个问题。多少的测试覆盖率是可以接受的?
有些人直接说100%,有些说90%,至少要达到50%。那是我至今听到的最低的数字。
我会从我的角度分解这个主题,分享一些我的观点。
多层问题
你有没有曾经跟一个同事发生过如下对话?
- Dude1: 我们要开始这个牛逼的工程了
- Dude2: Cool
- Dude1:你觉得我们是用Java/Scala/Node.js还是什么别的?
从某种意义上讲,编程语言的问题跟测试覆盖问题相似。他有许许多多的方面,这不是一个能够单独回答的问题— 除非你去谈论细节。
测试覆盖是一种适用于多种东西的术语:
- 单元测试覆盖
- 组件测试覆盖
- 集成测试覆盖
- API测试覆盖
- UI测试覆盖
- E2E测试覆盖
- 性能测试覆盖
当某些人问及此问题时,首先我们要确保的是在讨论相同的语言。最普遍的选择是从单元测试覆盖开始— 至少在我的经验中如此。
现在已经决定了,下一步就是来讨论不同的方式去度量单元测试覆盖:
- 行覆盖
- 分支覆盖
行覆盖是统计在测试时覆盖了哪些行。分支覆盖时统计在测试时覆盖了哪些路径。
也许你想说“这个人在说啥!根本就没有解释清楚嘛!”。确实是干巴巴的解释,让我给你举个例子吧。
假设我们有如下(Java)代码:
public void bar(int x) {
if (x > 10)
System.out.println(x);
}
在这个方法中,我们有两行能够被执行,并且有两个分支。一个是当x比10大,一个是隐藏分支—当它小于或者等于时。后者,控制台上将什么都不会打印出来。
我们来简单的测试一下。
class FooTest {
@Test
public void barTest() {
new Foo().bar(11);
}
}
我们用11来再测一下。在那个执行路径上,if条件如果判断为true,值就会被打印到控制台上。
我们的数据长什么样?
- 行覆盖:2/2(100%)
- 分支覆盖:1/2(50%)
就像你看到的,如果你只是看行覆盖,你会觉得非常安全;“我的代码已经完整测试了”。当看到分支覆盖时,很明显有一个缺失的执行路径。
这是两个基本指标的区别,那也是为什么原始的那个问题不是非黑即白的。
所以到底多少呢?
我觉得没什么经验的开发者和面试官会很期待覆盖率趋近于100%。我个人不认为这是正确的。
人们认为更高的测试覆盖意味着代码质量越好的唯一原因是因为他们认为两者之间有直接关系。他们离真理不远了。
经常的,当讨论测试时,我们去忘记测试是需要耗时的。并且它不是一个一次性的小号,因为测试需要持久性。说的多一点,黑盒和白盒测试也有不同程度的耗时。
简单介绍一下我的意思 — 定义不一定100%准确,但你会get到我的点。
黑盒单元测试不需要关心所测单元的内部实现。你根据需求提供一个输入,针对输出做一个验证。
假设有如下代码:
public class Foo {
public int bar(int x) {
if (x > 10) {
return x + 1;
}
return x;
}
}
相关case:
class FooTest {
@Test
public void barTest() {
Foo foo = new Foo();
int result = foo.bar(11);
assertEquals(12, result);
}
}
这是个黑盒测试。为什么?我用11调用了bar方法,期望返回12。我这种验证方法不关心该方法的实现是否已经根据需求实现了—在这个case中需求是如果x比10大,就加1,如果不是就不做。
想你看到的,如果需求改变成,如果x大于10,就给x加2,我可以很简单的改变测试。不是什么大问题。测试最终会代表单元的实际需求。真棒。
另一方面,白盒测试需要验证单元的实现。
前面的例子稍稍变动:
public class Foo {
private ParamCreator paramCreator;
private Calculator calculator;
public Foo(ParamCreator paramCreator, Calculator calculator) {
// omitted
}
public int bar(int x) {
if (x > 10) {
CalculatorParam param = paramCreator.create(x, 1);
return calculator.add(param);
}
return x;
}
}
我做的改变是,将计算逻辑提取到Calculator类,再建一个ParamCreator类为Calculator创建参数对象。
为了保证测试是一个单独的单元在单元测试层级,我会用到模拟。
@ExtendWith(MockitoExtension.class)
class FooTest {
@Mock
private ParamCreator paramCreator;
@Mock
private Calculator calculator;
@InjectMocks
private Foo foo;
@Test
public void barTest() {
CalculatorParam calculatorParam = new CalculatorParam(11, 1);
Mockito.when(paramCreator.create(11, 1)).thenReturn(calculatorParam);
Mockito.when(calculator.add(calculatorParam)).thenReturn(12);
int result = foo.bar(11);
assertEquals(12, result);
Mockito.verify(paramCreator).create(11, 1);
Mockito.verify(calculator).add(calculatorParam);
}
}
创建模拟,把它注入Foo,就好了。测试用例变得有些难懂。为了保证模拟器正常运行,我们需要准确的知道实现。测试的第一部分应当包括setup,当我基于方法调用的参数来做mock行为时。调用bar方法,然后验证结果 — 从mock返回的,也同时可以验证mock是否被真实的调用到。
回到白盒测试。当你在测试中使用mock时,你就是在做白盒测试啦。我认为一个关键的缺点是它带来的耦合的上涨。
想象一下我想要改代码,然后在Foo类中创建一个参数对象。上述的测试场景就会失败,因为ParamCreator类根本就没有被调用到。
ROI
针对黑盒和白盒测试,应该有不同的标准。黑盒测试持久化会更加容易一些。你改变了行为,在测试中就会付出相应的代价。在白盒测试中,你改变了实现,测试需要被重写即使行为根本就没被改变。
还有,还有一个小观点,成为“有意义的测试”。每个人都可以写测试用例来保证测试覆盖是100%。但是问题是,不是100%的话,多少是有意义的呢?这是另一个非常有趣的度量。
度量有意义的测试是另一个话题,我不会深入的去聊 — 也许在下一个文章中。在我的观点里,没有什么好的办法,也许甚至不是一个工具就可以去做这样的度量,但是最接近的观点是突变检测。
我们讨论一点关于ROI的东西,这个概念每一个开发者、QA工程师都应该知道,Return On Investment。简单来说,就像是投资之后获取回报。这是一个大概的自动化测试的ROI的图表。
对于自动化测试来说,你最开始付出了时间来节约手工测试所消耗的时间。
我认为这个图表在problem space上过于简单化。当一个人期望在第二部分收获价值时,他们默认假设这些测试是0投入的。
答案
对于最开始的问题的答案依赖许多因素。像我说的测试有许多属性,你正在用的特定的环境呢?
我们需要为类似Netflix的产品写相同数量的测试吗?或者一个内部的工具只有5个hr再用?或者对于一个本地的小商店的网站呢?
这也没有什么最优解。我只是觉得,在有更多问题需要解决的时候,人们总是坚持90%-100%的覆盖是有问题的。如果你问我,最佳答案是一系列的其他问题,像我们在讨论什么环境,项目排期如何,客户端多大,我们对这个产品支持多久和解释有意义测试的重要性,还有正确的使用代码覆盖度量。
考虑到所有这些,我有一个60%有意义的测试覆盖指标。通常我不愿意低于这个指标,但是我也见过项目覆盖率只有30%-40%也能运行的不错。当然,这个数字要基于很多环境的因素。