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

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

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

什么是代码覆盖率?

代码覆盖率是一项指标,用于衡量测试执行的源代码所占的百分比。它可以帮助您找出可能缺少适当测试的方面。

通常,记录这些指标的操作如下所示:

文件 % 语句 分支 函数百分比 线条占比 未覆盖的行
file.js 90% 100% 90% 80% 89256
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);
}

代码行覆盖率衡量的是测试套件执行的可执行代码行所占的百分比。如果某行代码仍未执行,则表示代码的某些部分尚未经过测试。

该代码示例包含八行可执行代码(以红色和绿色突出显示),但测试不会执行 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. 正在呼叫 calcCoffeeIngredient,只呼叫 coffeeName叉号。
  2. 正在呼叫calcCoffeeIngredient(号码为coffeeNamecup叉号。
  3. 咖啡是浓缩咖啡 叉号。
  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);
}

语句覆盖率衡量代码中由测试执行的语句所占的百分比。乍一看,您可能会好奇:“这不就是行覆盖率吗?”实际上,语句覆盖率与行覆盖率类似,但需要考虑包含多个语句的单行代码。

在代码示例中,有八行可执行代码,但有九条语句。您能找出包含两个语句的行吗?

检查答案

如下所示:espresso = 30 * cup; water = 70 * cup;

测试仅涵盖九个语句中的五个,因此语句覆盖率为 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) 测试。由于 E2E 测试的复杂性,衡量 E2E 测试的代码覆盖率非常困难且颇具挑战性。与使用代码覆盖率相比,要求覆盖率可能是更好的选择。这是因为,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({});
  });
});