• test metrics
    • line coverage
    • branch coverage
    • 不可能用测试来保证无bug
  • 测试的价值
    • 不是所有测试都提供了等同的价值
  • test四象限:true positive, false positive, true negative,false negative
  • Conclusion:
    • 测试覆盖

我在一个新起的项目上参与估点。一个普通的CRUD接口,估了5个点,还是在pair的情况下。我感觉很宽松。但在实际做卡后,我发现5个点只是勉强够用。除去开发时间被过多的会议占用以外,我想最大的贡献就是测试了。我们项目在一开始就覆盖了各种各样的测试,包括单元测试、集成测试、契约测试,以及E2E测试。每一张业务卡都要在测试金字塔的各个层级上编写测试。我们写测试的时间两道三倍于我们写实现的时间。这不禁让我陷入了思考:测试能给我们带来什么好处?测试将怎样影响我们的软件质量和交付速度?怎么去评价一个测试?

测试的作用

自动化测试最本质的作用是替代一部分的人工测试。

结论先行

好的测试在软件开发过程中固定了一部分不变量(invariant),这使得我们可持续地构建我们的软件。我们使用测试覆盖率这一指标来迫使我们为软件编写一定规模的测试,但是测试覆盖率是一个单向的指示器:较低的覆盖率是一个危险的信号,让我们在系统复杂到一定程度后没有信心去阻止软件回归,但是高覆盖率并不一定意味着我们有健壮的测试——一味地追求高覆盖率,更容易让我们的系统中出现大量的无效测试。一个好的测试不但要能检测回归,还要能抵抗重构。

静态类型语言的类型系统就像类型系统可以在编译过程,可以阻止软件回归,这使得我们可以持续性地构建软件。但是即使编写再多的测试,我们依然无法保证 即使编写再多的测试,我们也保证我们的系统没有Bug, 给我们带来可持续的软件构建过程。测试覆盖率是我们最容易得到的一个测试指标,但是它只能作为一个单向的检测器:测试覆盖率较低的代码容易在重构或者更新需求时出现回归,但是金

测试的作用

测试与证明

测试最直观的作用就是防止回归。所谓的回归(Regression)指的是在一个事件发生后(通常为修改代码)我们的代码不能像预期的那样工作。严肃的软件系统通常拥有更多的测试。这给我们一个错觉:我们的系统有bug,是因为没有覆盖足够的测试;当我们把编写足够的测试把所有分支、所有边界条件都覆盖到,我们的系统是可以没有bug的。事实上,除非我们的输入空间是有限、可以穷举的,否则我们很难通过测试来保证我们的系统是没有bug的。

我们可以通过一个极端的例子作证这个观点。有一天,一个数学家发现

他花了点时间研究这个问题,发现下面的算式也成立:

于是,他猜想,对于任意,有:

作为一个软件工程师,我们为这个猜想编写测试。我们发现,对于n=0,1,2…,甚至对于相当大的数:至少对于任意的64位无符号整数,这个结论都成立。即使如此,我们也无法证明这个结论是正确的。事实上,直到我们测试到时,我们才能发现反例

测试与可持续开发

软件测试最大的价值在于保证了可持续开发过程。开发一个项目很容易,尤其是在项目初期。但是随着代码规模的增长,我们会发现为系统实现新的需求变的越来越难。

测试与类型系统

或许你觉得测试和类型系统是毫无关联的两个概念。实际上,它们为我们的软件系统提供了相似的价值。我们需要测试,就像我们需要静态类型系统一样。动态一时爽,重构火葬场。对于测试也可以说:不测一时爽,重构火葬场。静态类型语言在完成发布构建后,它们的类型信息一般会被擦除,因为我们已经在编译时知道我们的代码具有正确的类型,它不会影响代码的运行。测试和类型系统一样,我们可以把最终交付的代码中的测试文件完全删除,而毫不影响软件的运行。它们的价值在于,为软件开发过程固定了一部分不变量(invariant),在不同的层级捕捉到代码的回归,从而影响软件开发的动态过程。我们将不变量检查集成到了我们的构建过程或CI中,违法类型约束的代码将被编译器回绝,违法测试用例的代码将被测试框架回绝。

测试覆盖率

测试覆盖率可以说是最易得的测试指标。大部分软件项目都会对测试覆盖率做一定程度的要求。测试覆盖率的指标更进一步可以分为表达式覆盖率、分支覆盖率、函数覆盖率、行覆盖率等。我会以行覆盖率和分支覆盖率为例,通过一些简单的例子演示这些指标是怎么得到的,以及存在什么问题。

行覆盖率

行覆盖率指的是执行代码时,实际跑的代码行数与总代码行数的比值:

譬如下面的代码,

function isStringLong(input: string): boolean { 
 
  if (input.length > 5) // 1
    return true;        // 2
 
  return false;         // 1
}
 
 
it('return false given string length no more than 5', () => {
 
  const result = isStringLong('abc');
 
  expect(result).toBe(false);
 
})
  • 1: 被执行到的代码
  • 2: 未被执行的代码

更具体的,空行要不要计入行数、函数签名行要不要计入行数这些细节可能不同的工具有不同的表现,我们暂时忽略这些细节。假定都不计入,以上代码的行覆盖率为66.7%。

如果我们将函数改写成下面的形式。

function isStringLong(input: string): boolean { 
  return input.length > 5 ? true : false;
}

它和原来的写法几乎完全一致,只是把if表达式换成了三元表达式。它带来的效果就是让我们的代码更紧凑,整个条件判断在一行就做完了,以及一个副作用:让我们的行覆盖率提到了100%。

分支覆盖率

可以看到行覆盖率会仅仅因为写法的不同产生不一样的输出,一些简便的写法甚至能直接将行覆盖率拉满。分支覆盖率能在一定程度上解决这个问题。分支覆盖率指的是执行代码时,实际执行的条件分支数与总分支数的比值:

可以认为,分支覆盖率是比行覆盖率更有价值的指标。在上面的两个例子中,input.length > 5创建了两个分支,而我们的测试只覆盖到了input.length > 5 === false这个分支。所以,在两种写法中,分支覆盖率都是50%。

眼尖的读者可能要强迫症发作了。是的,上面的函数可以重构成下面形式:

function isStringLong(input: string): boolean {
  return input.lengh > 5;
}

它带来的另一个神奇的副作用是,我们测试的分支覆盖率也成了100%:因为这里完全没有分支。由此可以看出,即使是分支覆盖率也有一定的局限性,即使覆盖率拉到了100%,系统中仍可能有未测到的逻辑。

确实,我前面举了一个很特殊的例子。大部分的分支都无法简化成这种形式。测试覆盖率更一般的局限性在于:无法测试你引用的第三方的代码。下面是一个简单的例子。

function parseNumber(input: string): number {
  return parseInt(input);
}
 
it('return number 5 given string 5', () => {
  const result = parseNumber('5');
  expect(result).toBe(5);
})

这个函数同样没有分支,我们的测试覆盖率很轻松地达到了100%。如果我们能保证我们在调用这个函数时传入字符串一定是只包含数字的、没有前导0的、不是特别大的正整数,我们的测试可以就此停下。但通常,我们并不满意这么一个简单的测试用例,即使它的覆盖率达到了100%。

结论

测试覆盖率作为一个易得的测试指标,在很多项目中都有强制性要求。从本节的例子可以看出,测试覆盖率可能是个不错的消极指标:较低的测试覆盖率往往意味着我们的代码缺乏足够的测试;但它并不是一个好的积极指标:即使各种意义上的测试覆盖率都达到了100%,我们仍可能遗漏关键的测试用例。一个更好地看待测试覆盖率的方式是简单地把它作为一个指示器,而非一个必须达成的目标。就像我们通过体温判断一个感冒患者的病情一样,超过37°C往往意味着一个人发烧了,我们更应该关注的是造成体温异常背后的原因而不是体温本身。盲目地控制体温远比让患者康复容易,甚至有可能在治疗过程中起到消极的作用。

测试的价值与代价

测试不是免费的,测试的代价体现在运行和维护测试所花费的时间中, 包括但不限于:

  • 编写和运行测试
  • 阅读和理解测试代码
  • 修复因重构而失败的测试

不是所有的测试都提供了等同的价值,也不是所有的测试都需要等同的代价。测试的价值和代价是两个独立的维度,且并不是正相关的。甚至,我们希望通过良好的代码组织让他们负相关。也即:重要的代码是最好是易测的,而难测的代码尽可能只包含简单的逻辑。制定测试策略时,我们应该力求通过最小的代价,获得最大的价值。

我们回归到测试的本质:阻止软件回归。进而思考什么样的代码需要测试。我们可以从两个维度评价一段代码是否需要测试:是否对业务起到重要的作用,以及是否容易犯错。

那么测试的难度又受什么影响呢?当我们编写测试时,依赖

两个维度构成了代码的测试价值:

  • 领域相关代码
  • 高复杂度的代码