四种常见的代码覆盖率类型

了解什么是代码覆盖率,以及四种常见的代码覆盖率衡量方法。

您是否听说过“代码覆盖率”一词?在本文中,我们将探讨测试中的代码覆盖率以及衡量代码覆盖率的四种常用方法。

代码覆盖率是一种衡量测试执行的源代码百分比的指标。这有助于您找出可能未经过适当测试的方面。

通常,记录这些指标的代码如下所示:

文件 % 语句 % 分支 % 函数 占行数百分比 未覆盖的行
file.js 90% 100% 90% 80% 89,256
coffee.js 55.55% 80% 50% 62.5% 10-11, 18

随着您添加新功能和测试,提高代码覆盖率有助于您更放心地确定应用已得到全面测试。不过,还有更多内容值得您去探索。

四种常见的代码覆盖率

收集和计算代码覆盖率有四种常用方法:函数覆盖率、行覆盖率、分支覆盖率和语句覆盖率。

四种类型的文本覆盖率。

如需了解每种类型的代码覆盖率是如何计算其百分比的,请考虑以下用于计算咖啡成分的代码示例:

/* 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 函数的测试如下:

/* 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({});
  });
});

您可以在此实时演示中运行代码和测试,也可以查看代码库

函数覆盖率

代码覆盖率:50%

/* coffee.js */

export function calcCoffeeIngredient(coffeeName, cup = 1) {
  // ...
}

function isValidCoffee(name) {
  // ...
}

函数覆盖率是一个简单的指标。它会捕获测试调用的代码中函数的百分比。

在代码示例中,有两个函数:calcCoffeeIngredientisValidCoffee。这些测试仅调用 calcCoffeeIngredient 函数,因此函数覆盖率为 50%。

线路覆盖率

代码覆盖率:62.5%

/* 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 行可执行代码(以红色和绿色突出显示),但测试不会执行 americano 条件(两行)和 isValidCoffee 函数(一行)。这会导致行覆盖率为 62.5%。

请注意,行覆盖率不会考虑 function isValidCoffee(name)let espresso, water; 等声明语句,因为它们不可执行。

分支覆盖率

代码覆盖率:80%

/* coffee.js */

export function calcCoffeeIngredient(coffeeName, cup = 1) {
  // ...

  if (coffeeName === 'espresso') {
    // ...
    return { espresso };
  }

  if (coffeeName === 'americano') {
    // ...
    return { espresso, water };
  }

  return {};
}

分支覆盖率用于衡量代码中已执行分支或决策点(例如 if 语句或循环)所占的百分比。它用于确定测试是否同时检查条件语句的真分支和假分支。

该代码示例中有五个分支:

  1. 仅使用 coffeeName 对勾标记。 调用 calcCoffeeIngredient
  2. 使用 coffeeNamecup 调用 calcCoffeeIngredient 对勾标记。
  3. 咖啡是 Espresso 对勾标记。
  4. 咖啡是美式咖啡 X 标记。
  5. 其他咖啡 对勾标记。

这些测试涵盖除 Coffee is Americano 条件之外的所有分支。因此,分支覆盖率为 80%。

语句覆盖率

代码覆盖率:55.55%

/* 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 行可执行代码,但有 9 个语句。您能找到包含两个语句的行吗?

它是以下行:espresso = 30 * cup; water = 70 * cup;

测试仅涵盖 9 个语句中的 5 个,因此语句覆盖率为 55.55%。

如果您始终在一行中编写一条语句,那么行覆盖率将与语句覆盖率相似。

您应选择哪种类型的代码覆盖率?

大多数代码覆盖率工具都包含这四种常见的代码覆盖率。选择要优先考虑的代码覆盖率指标取决于具体项目要求、开发实践和测试目标。

一般来说,语句覆盖率是一个不错的起点,因为它是一个简单易懂的指标。与语句覆盖率不同,分支覆盖率和函数覆盖率用于衡量测试是调用条件(分支)还是函数。因此,它们是语句级覆盖率之后的自然延伸。

实现较高的语句覆盖率后,您可以继续提高分支覆盖率和函数覆盖率。

测试覆盖率是否与代码覆盖率相同?

不可以。测试覆盖率和代码覆盖率经常被混淆,但它们是不同的:

  • 测试覆盖率:一种定性指标,用于衡量测试套件对软件功能的覆盖率。这有助于确定所涉及的风险级别。
  • 代码覆盖率:一种量化指标,用于衡量测试期间执行的代码所占的比例。这与测试涵盖的代码量有关。

下面是一个简化的类比:假设 Web 应用是一栋房子。

  • 测试覆盖率用于衡量测试对住宅中各个房间的覆盖率。
  • 代码覆盖率衡量测试已覆盖的代码量。

代码覆盖率 100% 并不意味着没有 bug

虽然在测试中实现较高的代码覆盖率无疑是理想的,但 100% 的代码覆盖率并不能保证代码中没有 bug 或缺陷。

实现 100% 代码覆盖率的无意义方法

请考虑以下测试:

/* 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); // not meaningful assertion
  });
});

此测试可实现 100% 的函数、行、分支和语句覆盖率,但没有意义,因为它实际上并未测试代码。无论代码能否正常运行,expect(true).toBe(true) 断言始终会通过。

指标不佳比没有指标更糟糕

不良指标可能会让您产生虚假的安全感,这比没有指标更糟糕。例如,如果您的测试套件实现了 100% 的代码覆盖率,但测试都毫无意义,那么您可能会误以为自己的代码已得到充分测试,从而产生虚假的安全感。如果您不小心删除或破坏了应用代码的一部分,即使应用无法正常运行,测试仍会通过。

为避免这种情况,请执行以下操作:

  • 测试审核。编写和审核测试,确保测试有意义,并在各种不同场景中测试代码。
  • 将代码覆盖率用作指导,而不是衡量测试效果或代码质量的唯一指标。

在不同类型的测试中使用代码覆盖率

我们来详细了解如何将代码覆盖率与三种常见的测试类型搭配使用:

  • 单元测试。它们是收集代码覆盖率的最佳测试类型,因为它们旨在涵盖多个小场景和测试路径。
  • 集成测试。它们可以帮助收集集成测试的代码覆盖率,但请谨慎使用。在这种情况下,您计算的是源代码的更大部分的覆盖率,因此可能很难确定哪些测试实际上涵盖了代码的哪些部分。不过,对于没有良好隔离单元的旧版系统,计算集成测试的代码覆盖率可能很有用。
  • 端到端 (E2E) 测试。由于这些测试的复杂性,衡量端到端测试的代码覆盖率是一项艰巨的任务。与使用代码覆盖率相比,使用要求覆盖率可能更好。这是因为端到端测试的重点是涵盖测试的要求,而不是关注源代码。

总结

代码覆盖率是一种衡量测试效果的实用指标。它可以确保对代码中的关键逻辑进行充分测试,从而帮助您提高应用质量。

不过,请注意,代码覆盖率只是一个指标。请务必考虑其他因素,例如测试质量和应用要求。

目标不是实现 100% 的代码覆盖率。相反,您应将代码覆盖率与全面的测试计划结合使用,其中包含各种测试方法,包括单元测试、集成测试、端到端测试和手动测试。

查看完整的代码示例和具有良好代码覆盖率的测试。您还可以使用此实时演示运行代码和测试。

/* 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);
}
/* 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({});
  });
});