原文作者 Jecelyn Yeen 发表于 web.dev/ta-code-cov...
你是否听说过"代码覆盖率"这个词?在这篇文章中,我们将探索在测试中,什么是代码覆盖率,以及4种常见的测量方式。
什么是代码覆盖率
代码覆盖率是一种衡量你的测试用例执行的源代码百分比的度量标准。它有助于你识别可能缺乏适当测试的代码区域。
通常,记录这些度量指标会像这样:
File | % Statements | % Branch | % Functions | % Lines | Uncovered lines |
---|---|---|---|---|---|
file.js | 90% | 100% | 90% | 80% | 89256 |
coffee.js | 55.55% | 80% | 50% | 62.5% | 10-11,18 |
当你添加新功能和测试时,提高的代码覆盖率百分比让你对你的应用经过了充分测试充满了信心。但是,还可以去更深入了解覆盖率。
4种常见的代码覆盖类型
有四种常见的方式来收集和计算代码覆盖率:函数(function)覆盖率、行(line)覆盖率、分支(branch)覆盖率和语句(statement)覆盖率。
要了解每种类型的代码覆盖率如何计算其百分比,可以以下用于计算咖啡配方的代码示例:
js
/* coffee.js */
export function calcCoffeeIngredient(coffeeName, cup = 1) {
let espresso, water;
if (coffeeName === 'espresso') {
espresso = 30 * cup;
return { espresso };
}
if (coffeeName === 'americano') {
espresso = 30 * cup; water = 70 * cup;
return { espresso, water };
}
return {};
}
export function isValidCoffee(name) {
return ['espresso', 'americano', 'mocha'].includes(name);
}
用来测试calcCoffeeIngredient
的代码:
js
/* coffee.test.js */
import { describe, expect, assert, it } from 'vitest';
import { calcCoffeeIngredient } from '../src/coffee-incomplete';
describe('Coffee', () => {
it('should have espresso', () => {
const result = calcCoffeeIngredient('espresso', 2);
expect(result).to.deep.equal({ espresso: 60 });
});
it('should have nothing', () => {
const result = calcCoffeeIngredient('unknown');
expect(result).to.deep.equal({});
});
});
函数(function)覆盖率
代码覆盖率:50%
js
/* coffee.js */
export function calcCoffeeIngredient(coffeeName, cup = 1) {
// ...
}
function isValidCoffee(name) {
// ...
}
函数覆盖率是一个直接的度量指标,他展示的是在你的测试中有多少函数被调用到的百分比。 在这个代码片段中,有两个函数calCoffeeIngredient
和isValidCoffee
,单元测试只调用到了calCoffeeIngredient
,所以函数覆盖率是50%。
行(line)覆盖率
代码覆盖率:62.5%
js
/* coffee.js */
export function calcCoffeeIngredient(coffeeName, cup = 1) {
let espresso, water;
if (coffeeName === 'espresso') {
espresso = 30 * cup;
return { espresso };
}
if (coffeeName === 'americano') {
espresso = 30 * cup; water = 70 * cup;
return { espresso, water };
}
return {};
}
export function isValidCoffee(name) {
return ['espresso', 'americano', 'mocha'].includes(name);
}
行覆盖率测展示的是你的单元测试执行到的被测代码中可执行代码行的百分比。
在这个例子中有8行属于可执行代码(6\7\8\11\12\13\16\20),但是单元测试没有执行到americano
条件判断(12\13)和isValidCoffee
这个方法(20),所以行覆盖率是62.5%。
注意,行覆盖率不把声明语句纳入统计,比如function isValidCoffee(name)
和 let espress, water
,因为他们不可执行。
分支(branch)覆盖率
代码覆盖率:80%
js
/* coffee.js */
export function calcCoffeeIngredient(coffeeName, cup = 1) {
// ...
if (coffeeName === 'espresso') {
// ...
return { espresso };
}
if (coffeeName === 'americano') {
// ...
return { espresso, water };
}
return {};
}
...
分支覆盖率衡量了代码中执行的分支或决策点的百分比,比如if语句或循环。它确定测试是否检查了条件语句的真假分支。 示例中有5个分支:
- 1. 调用
calcCoffeeIngredient
只传coffeeName
入参 - 2. 调用
calcCoffeeIngredient
同时传入coffeeName
和cup
- 3. coffee 是 espresso
- 4. coffee 是 americano
- 5. 其他 coffee
测试覆盖了除了coffee 是 americano
的其他情况,所以分支覆盖率是80%。
语句(statement)覆盖率
代码覆盖率:55.55%
js
/* coffee.js */
export function calcCoffeeIngredient(coffeeName, cup = 1) {
let espresso, water;
if (coffeeName === 'espresso') {
espresso = 30 * cup;
return { espresso };
}
if (coffeeName === 'americano') {
espresso = 30 * cup; water = 70 * cup;
return { espresso, water };
}
return {};
}
export function isValidCoffee(name) {
return ['espresso', 'americano', 'mocha'].includes(name);
}
语句覆盖率(Statement coverage)衡量了你的测试执行的代码中的语句百分比。乍一看,你可能会想:"这不就是与行覆盖率(Line coverage)相同吗?"的确,语句覆盖率与行覆盖率相似,但它考虑了包含多个语句的单行代码。 在这个例子中有8行可执行代码,但是有9行语句,比如espresso = 30 * cup; water = 70 * cup;
是1行可执行代码,但是有2个语句。
测试只覆盖了9行中的5行,所以语句覆盖率是55.55%。
如果你总是每一行都写一个语句,那么你的行覆盖率和语句覆盖率会很接近。
应该选择哪个度量指标?
大多数代码覆盖工具都包括这四种常见的代码覆盖类型。选择优先考虑哪种代码覆盖度指标取决于特定项目要求、开发实践和测试目标。
总的来说,语句覆盖率是一个很好的起点,因为它是一个简单且易于理解的指标。与语句覆盖率不同,分支覆盖率和函数覆盖率衡量测试是否调用了条件(分支)或函数。因此,在语句覆盖率之后,自然的会追求增加他们的覆盖率。
一旦你达到了较高的语句覆盖率,你就可以继续关注分支覆盖率和函数覆盖率。
测试覆盖率与代码覆盖率相同吗?
不,测试覆盖率和代码覆盖率经常会混淆,但它们是不同的:
- 测试覆盖率:一种定性的度量,用于衡量测试套件对软件功能的覆盖程度。它有助于确定涉及的风险水平。
- 代码覆盖率:一种定量的度量,用于衡量在测试过程中执行的代码比例。它关注的是测试覆盖了多少代码。
这里有一个简单的类比:想象一个 web 应用程序就像一座房子。
- 测试覆盖率衡量了测试用例对房子中各个房间的覆盖程度。
- 代码覆盖率衡量了测试在房子中走过了多少部分。
100%代码覆盖率意味着没有bug?
虽然在测试中达到高的代码覆盖率是可取的,但100%的代码覆盖率并不能保证代码中没有错误或缺陷。
一种无意义的达到100%代码覆盖率的方法
思考一下以下的代码:
js
/* coffee.test.js */
// ...
describe('Warning: Do not do this', () => {
it('is meaningless', () => {
calcCoffeeIngredient('espresso', 2);
calcCoffeeIngredient('americano');
calcCoffeeIngredient('unknown');
isValidCoffee('mocha');
expect(true).toBe(true); // 无意义断言
});
});
这个测试实现了100%的函数、行、分支和语句覆盖率,但它没有意义,因为它实际上并没有测试代码。expect(true).toBe(true)
断言将始终通过,无论代码是否正确工作。
一个糟糕的度量指标比没有度量指标更糟糕
一个糟糕的度量指标可能会给你一种错误的安全感,这比没有度量指标更糟糕。例如,如果你有一个测试套件,它实现了100%的代码覆盖率,但所有的测试都是毫无意义的,那么你可能会错误地认为你的代码经过了充分的测试。如果你意外地删除或破坏了应用程序代码的一部分,测试仍然会通过,尽管应用程序已经不能正常工作了。这说明了一个糟糕的度量指标可能会导致误导,让你对代码的质量产生错误的认识。
为了避免这种情况:
- 评审用例,编写和评审用例可以确保他们具有实际意义并测试了代码在各种不同情境下的行为。
- 将代码覆盖率视为一个指导方针,而不是测试有效性或代码质量的唯一衡量标准。
在不同类型的测试中使用代码覆盖率作为指标
让我们更详细地了解如何在三种常见的测试类型中使用代码覆盖率:
- 单元测试。它们是收集代码覆盖率的最佳测试类型,因为它们旨在涵盖多个小场景和测试路径。
- 集成测试。它们可以帮助收集集成测试的代码覆盖率,但需要谨慎使用。在这种情况下,您计算较大部分源代码的覆盖率,很难确定哪些测试实际上覆盖了代码的哪些部分。尽管如此,计算集成测试的代码覆盖率对于没有良好隔离单元的遗留系统可能是有用的。
- 端到端(E2E)测试。测量端到端测试的代码覆盖率是困难且具有挑战性的,这是因为这些测试的性质较为复杂。与使用代码覆盖率不同,可能更好的方法是使用需求覆盖率。这是因为端到端测试的重点是覆盖您的测试需求,而不是专注于源代码。
总结
代码覆盖率可以是衡量测试有效性的有用指标。它可以帮助你通过确保代码中的关键逻辑得到充分测试来提高应用程序的质量。
但是,请记住,代码覆盖率只是一个指标。确保还考虑其他因素,如测试的质量和应用程序的需求。
追求100%的代码覆盖率并不是目标。相反,你应该使用代码覆盖率以及综合性的测试计划,其中包括各种测试方法,包括单元测试、集成测试、端到端测试和手动测试。
你可以参考下面的完整示例:
js
/* coffee.js - a complete example */
export function calcCoffeeIngredient(coffeeName, cup = 1) {
if (!isValidCoffee(coffeeName)) return {};
let espresso, water;
if (coffeeName === 'espresso') {
espresso = 30 * cup;
return { espresso };
}
if (coffeeName === 'americano') {
espresso = 30 * cup; water = 70 * cup;
return { espresso, water };
}
throw new Error (`${coffeeName} not found`);
}
function isValidCoffee(name) {
return ['espresso', 'americano', 'mocha'].includes(name);
}
js
/* coffee.test.js - a complete test suite */
import { describe, expect, it } from 'vitest';
import { calcCoffeeIngredient } from '../src/coffee-complete';
describe('Coffee', () => {
it('should have espresso', () => {
const result = calcCoffeeIngredient('espresso', 2);
expect(result).to.deep.equal({ espresso: 60 });
});
it('should have americano', () => {
const result = calcCoffeeIngredient('americano');
expect(result.espresso).to.equal(30);
expect(result.water).to.equal(70);
});
it('should throw error', () => {
const func = () => calcCoffeeIngredient('mocha');
expect(func).toThrowError(new Error('mocha not found'));
});
it('should have nothing', () => {
const result = calcCoffeeIngredient('unknown')
expect(result).to.deep.equal({});
});
});