单元测试简单的示例都以一个计算器为例开始,那么今天我们就来演示一下。
public int add(int x, int y) {
return x + y;
}
你认为我们需要多少个测试来覆盖这段代码?
“覆盖率”这个概念通常比较模糊。人们常说的“足够多的测试”到底是指多少个测试呢?
我在工作中经常问这个问题(当然不仅仅是单元测试)。我得到各种各样的答案,比如1和1,2和2,大数字和负数,这些都是很好的用例,通常会找出5至15个合适的用例。
现在想想看:这些真的是好的测试吗?我们真正在测试什么,这些用例都是有用的吗?
这并不让人感到意外。比如:
return 1+1;
真正执行计算的代码深藏在“+”运算符内部。实际上,它位于隐式赋值运算符“=”和一些内存操作中。
当我们将两个非常大的数字相加时会发生什么?会引发一个异常。该异常由……抛出。
我们也可以为这些情况编写测试,检查是否在较大的值时抛出异常。
简单的东西反而测试起来复杂
回到最初的问题:我们需要多少测试覆盖这个方法才感到放心呢?
可以肯定的是,测试肯定是有用的。它们会告诉我们预期的行为是否发生了变化。这些测试何时会失败?有几个可能。
在第一个选项中,如果我们不小心替换了操作符为“/”,测试用例会告诉我们会执行错误。例如,4+2,预案来预期6,但是实际2,就会得到不同的结果。
public int add(int x, int y) {
return x / y;
}
还有哪些无法通过测试?
也许这是操作系统的一个漏洞?但这些操作系统每天都被全球数百万人使用。如果存在漏洞,我们很可能会听说的。
实际上,这些测试将永远通过。但这并不是好的测试。它们会让我们产生虚假的自信,认为代码运行正常。我们没有见过失败的测试。那么,这对我们其他的测试又会产生怎样的影响呢?对某些测试的信任度较低意味着整体信任度较低。
让我们回到最初的问题。我们需要多少测试来覆盖这段代码?
也许答案是“没有”,因为如果代码很简单,我们可能就不需要围绕它编写测试。当有人进入代码并对其进行更改,使其变得更加复杂时,他们就会添加测试来防止他们引入的复杂性。
如果我是在使用TDD(Test-Driven Development)进行开发,情况会怎样呢?我会先编写测试代码,然后再编写代码本身。这意味着我最终可能只会有一个测试用例。
相同的代码,不同的Package
现在,相同的代码不再以函数运行,而是在API内部运行。
这是基于Spring的实现,用于通过POST请求添加两个数字。
@PostMapping("/add")
public CalculationResult add(@RequestBody CalcRequest request) {
int sum = request.getNumber1() + request.getNumber2();
return new CalculationResult(sum);
}
API测试不仅会运行我们的代码,还会运行大量Spring代码。包括接收请求、返回响应、将JSON转换为对象等。更不用提那些未处理的异常了。
这一次,可能会有比原来代码多得多的其他代码导致测试失败,也可能是因为配置问题导致失败。比如,如果服务器没有运行,那么测试就会失败,但这与代码无关。
所以问题不在于“覆盖率”。而是我们希望从测试中学到什么。
如果我们只关心自己的代码,就会回到之前的答案。但这次是有代价的。我们假设API测试的任何失败都与代码变更无关,因此不需要调试或重试。可以忽略失败。只有当测试通过时,它们才具有价值。
另一个选择是进行测试以验证所有内容,包括我们的代码。如果测试失败,就意味着某个地方出了问题,我们需要花时间进行调试、查看日志并修复配置,但这些工作可能毫无价值。
在进行测试时,我们需要了解我们正在测试的内容、什么会使测试通过以及什么会使测试失败。
我们的测试旨在为我们提供信息。如果我们在编写测试之前没有考虑这一点,我们就会编写一些没用的测试,并在它们上面浪费大量宝贵的时间。