第 9 章 覆盖率

在第 7 章 "策略"(Strategy)(第 99 页)中,我们通过分析 "卡片 "项目面向用户的功能,根据测试策略生成了测试用例的初始列表。本书源代码 ch7 目录中的测试就是这些测试用例的实现,它们通过 API 对 Cards 进行测试。但我们如何知道这些测试是否彻底测试了我们的代码呢?这就是代码覆盖率的作用所在。

测量代码覆盖率的工具会在测试套件运行时观察你的代码,并跟踪哪些行被测试到,哪些没有被测试到。这种测量方法称为行覆盖率,计算方法是将运行的总行数除以代码总行数。

代码覆盖率工具还能告诉你控制语句中是否走完了所有路径,这种测量方法称为分支覆盖率。代码覆盖率不能告诉你测试套件是否优秀;它只能告诉你有多少应用程序代码被测试套件击中。但这本身就是有用的信息。

Coverage.py 是测量代码覆盖率的首选 Python 覆盖率工具。pytest-cov 是一个流行的 pytest 插件,经常与 coverage.py 结合使用,使命令行更简短。在本章中,我们将使用这两个工具来查看我们在上一章为 Cards 项目开发的测试套件中是否遗漏了任何重要内容。

使用 coverage.py 与 pytest-cov

coverage.py 和 pytest-cov 都是第三方包,使用前需要安装:

$ pip install coverage
$ pip install pytest-cov

要使用 coverage.py 运行测试,需要添加 --cov 标志,并提供要测量的代码的路径或要测试的安装包。在我们的例子中,Cards 项目是一个已安装的软件包,因此我们将使用 --cov=cards 对其进行测试。

正常的 pytest 输出之后是覆盖率报告,如图所示:

$ cd /path/to/code
$ pytest --cov=cards ch7
============================ test session starts =============================
collected 27 items
ch7/test_add.py ..... [ 18%]
ch7/test_config.py . [ 22%]
ch7/test_count.py ... [ 33%]
ch7/test_delete.py ... [ 44%]
ch7/test_finish.py .... [ 59%]
ch7/test_list.py .. [ 66%]
ch7/test_start.py .... [ 81%]
ch7/test_update.py .... [ 96%]
ch7/test_version.py . [100%]
---------- coverage: platform darwin, python 3.x.y -----------
Name Stmts Miss Cover
-------------------------------------------------------------------------
venv/lib/python3.x/site-packages/cards/__init__.py 3 0 100%
venv/lib/python3.x/site-packages/cards/api.py 72 3 96%
venv/lib/python3.x/site-packages/cards/cli.py 86 53 38%
venv/lib/python3.x/site-packages/cards/db.py 23 0 100%
-------------------------------------------------------------------------
TOTAL 184 56 70%
============================= 27 passed in 0.12s =============================

尽管我们没有直接调用覆盖率,但前面的输出是由覆盖率的报告功能产生的。pytest --cov=cards ch7 命令告诉 pytest-cov 插件:

  • 在运行 pytest 和 ch7 中的测试时,以 --source 设置为 cards 运行覆盖率,

  • 并为终端行覆盖率报告运行覆盖率报告。

我们可以直接使用覆盖率来完成这些工作。如果没有 pytest-cov,命令将如下所示:

$ coverage run --source=cards -m pytest ch7
$ coverage report

结果输出报告是一样的,这有点出人意料。尽管 Cards 的源代码位于 /path/to/code/cards_proj/src/cards 中,但覆盖率报告却是针对虚拟环境中已安装的软件包的。虚拟环境中指向 Cards 源文件的路径长得令人讨厌,但仍然很有用。虚拟环境路径是正确的路径,因为在测试过程中代码就运行在这个路径上。不过,代码也在本地 cards_proj 目录中。如果能列出本地 cards_proj 目录的覆盖范围就更好了。幸运的是,有一种变通方法可以告诉 coverage,本地 cards_proj 代码与已安装的代码相同,并使用本地位置代替。

如果使用本书的源代码尝试相同的命令,会得到不同的结果。原因是源代码中包含一个 .coveragerc 文件,内容如下:

coveragerc
Unresolved include directive in modules/ROOT/pages/section02/ch09/ch9.adoc - include::example$.coveragerc[]

该文件是 coverage.py 配置文件,其中的 source 设置告诉 coverage 将 cards_proj/src/cards 目录视为与 */site-packages/cards 中已安装的 cards 目录相同。星号 (*) 是一个通配符,可以让我们省去一些键入的麻烦,还能让路径适用于多个 Python 版本。输入整个 /path/to/venv/lib/python3.x/site-packages/cards 路径只能匹配一个特定的 Python 版本。

下面是 .coveragerc 更改后的输出结果:

$ pytest --cov=cards ch7
============================ test session starts =============================
collected 27 items
...actual test run omitted...
---------- coverage: platform darwin, python 3.x.y -----------
Name Stmts Miss Cover
cards_proj/src/cards/__init__.py 3 0 100%
cards_proj/src/cards/api.py 72 3 96%
cards_proj/src/cards/cli.py 86 53 38%
cards_proj/src/cards/db.py 23 0 100%
------------------------------------------------------
TOTAL 184 56 70%
============================= 27 passed in 0.12s =============================

报告现在列出的是本地文件,而不是安装位置。更短的路径有助于将我们的注意力集中到重要的部分:覆盖率报告。但是,根据我们对测试代码的了解,这份报告有意义吗?

__init__.py 和 db.py 文件的覆盖率是 100%,这意味着我们的测试套件命中了这些文件中的每一行。这并不能说明测试充分,也不能说明测试能捕捉到失败的可能性。但它至少告诉我们,每一行都在测试套件中运行过,这种感觉很好。

cli.py 文件的覆盖率为 38%。这似乎高得出人意料,因为我们还没有测试 CLI。简而言之,cli.py 被 __init__.py 导入,因此所有函数定义都被运行,但函数内容却没有运行。

我们现在真正关心的是 api.py 文件。它的测试覆盖率为 96%。这算好的吗?不好?我们还不知道。我们需要查看实际代码,看看哪些行被遗漏,才能知道测试这些行是否重要。我们可以通过终端报告或 HTML 报告找出遗漏的内容。

要将缺失的行添加到终端报告中,我们可以重新运行测试并添加 --cov-report=term-missing 标志,如下所示:

$ pytest --cov=cards --cov-report=term-missing ch7

或者我们可以像这样运行覆盖率报告 --show-missing :

$ coverage report --show-missing
Name Stmts Miss Cover Missing
----------------------------------------------------------------
cards_proj/src/cards/__init__.py 3 0 100%
cards_proj/src/cards/api.py 72 3 96% 75, 79, 81
cards_proj/src/cards/cli.py 86 53 38% 20, 28-31,
38-42, 53-65, 75-80, 87-91, 98-102, 109-110, 117-118, 126-127,
131-136, 143-146
cards_proj/src/cards/db.py 23 0 100%
----------------------------------------------------------------
TOTAL 184 56 70%

重要的是要知道,即使您使用 pytest-cov 运行覆盖率,您仍然可以直接使用覆盖率访问报告。

现在我们有了尚未测试的行的行号,我们可以在编辑器中打开文件并查看丢失的行。 然而,查看 HTML 报告更容易。

生成 HTML 报告

使用 coverage.py,我们能够生成 HTML 报告以帮助更详细地查看覆盖率数据。 该报告是通过使用 --cov-report=html 标志或在运行先前的覆盖率运行后运行覆盖率 html 生成的:

$ cd /path/to/code
$ pytest --cov=cards --cov-report=html ch7

$ pytest --cov=cards ch7
$ coverage html

任一命令都会要求 coverage.py 生成 HTML 报告。 该报告名为 htmlcov/,位于您运行该命令的目录中。

用浏览器打开 htmlcov/index.html,你会看到:

image 2024 02 02 19 15 19 903

单击 api.py 文件会显示该文件的报告,如下所示:

image 2024 02 02 19 15 42 139

报告顶部显示覆盖行的百分比 (96%)、语句总数 (72) 以及运行 (69)、错过 (3) 和排除 (0) 的语句数量。 向下滚动,您可以看到遗漏的突出显示的行:

image 2024 02 02 19 16 11 597

看起来 list_cards() 函数有几个可选参数 --owner 和 state,可以过滤列表。我们的测试套件没有测试这两行。

我们是否应该添加测试来练习这两行?如果我们回到我们的测试策略,请记住我们决定通过 API 测试用户可见的功能。如果用户在 CLI 中也能看到,那么用户就能看到。因此,让我们检查一下:

$ cards list --help
Usage: cards list [OPTIONS]
  List cards in db.
Options:
  -o, --owner TEXT
  -s, --state TEXT
  --help Show this message and exit.

是的。 卡列表命令允许传入这些内容。看起来我们在生成初始测试用例列表时错过了这一功能。 因此,我们至少需要将这些测试用例添加到我们的列表中:

  • 包含所有者的列表,按所有者过滤

  • 包含状态的列表,按状态过滤

  • 包含所有者和状态的列表,按两者过滤

这些测试用例应该符合我们缺少的三行,这很好,因为它们看起来是需要测试的重要功能。

从覆盖范围中排除代码

在上一节生成的 HTML 报告中,请注意其中包含指示 “0 排除” 的列。这是指覆盖率的一个特征,它允许我们从测试中排除某些行。 在 Cards 中,我们并不排除任何事情。 但将某些代码排除在覆盖率计算之外并不罕见。

例如,可以导入或直接运行的模块可能包含如下块:

if __name__ == '__main__':
  main()

如果我们直接调用模块(例如 python some_module.py),此命令告诉 Python 运行 main(),但如果模块被导入,则不运行代码。

这些类型的块经常被排除在使用简单的 pragma 语句的测试之外:

if __name__ == '__main__': # pragma: no cover
  main()

这告诉覆盖范围排除单行或代码块。 在此示例中,您不必将编译指示放在两行代码上。 将其放在 if 语句中对块的其余部分进行计数。

谨防覆盖率驱动的开发

使用 【第 127 页的生成 HTML 报告】中的 coverage.py 生成的覆盖率报告指示了哪些代码行没有由我们的测试套件运行,这有助于我们确定是否存在未测试但应该测试的功能。 在我们的案例中,报告表明存在三个合法的缺失测试用例。 然而,如果过滤器功能对用户不可见,因此不是我们测试策略的一部分,我们就会做出不同的决定,例如:

  • 我们是否应该将该功能添加到 CLI 中?

  • 我们是否应该从 API 中删除该功能?

  • 我们是否应该添加测试用例,因为我们计划很快将此功能添加到 CLI 中?

  • 我们是否应该接受低于 100% 的覆盖率?

  • 我们是否应该使用 #pragma: no cover 假装代码不存在?

  • 我们是否应该添加测试用例来覆盖这些行并让我们达到 100%?

我认为最后一个选择是最糟糕的。

特别是对于 Cards 中缺少的三行,pragma 选项同样糟糕。 然而,有时排除是有意义的,例如使用 __name__ == '__main__' 块时,如前面从覆盖范围中排除代码(第 129 页)中所讨论的。

其他都是合法的选择,具体取决于具体情况。

添加测试只是为了达到 100% 的问题在于,这样做会掩盖这些行未被使用的事实,因此应用程序不需要这些行。它还增加了不必要的测试代码和编码时间。

在测试中运行覆盖范围

除了使用覆盖率来确定我们的测试套件是否命中了应用程序代码的每一行。让我们将测试目录添加到覆盖率报告中:

$ pytest --cov=cards --cov=ch7 ch7
=========================== test session starts ============================
collected 27 items
...actual test run omitted...
---------- coverage: platform darwin, python 3.x.y -----------
Name Stmts Miss Cover
------------------------------------------------------
cards_proj/src/cards/__init__.py 3 0 100%
cards_proj/src/cards/api.py 71 3 96%
cards_proj/src/cards/cli.py 71 39 45%
cards_proj/src/cards/db.py 23 0 100%
ch7/conftest.py 22 0 100%
ch7/test_add.py 31 0 100%
ch7/test_config.py 2 0 100%
ch7/test_count.py 9 0 100%
ch7/test_delete.py 28 0 100%
ch7/test_finish.py 13 0 100%
ch7/test_list.py 11 0 100%
ch7/test_start.py 13 0 100%
ch7/test_update.py 21 0 100%
ch7/test_version.py 5 0 100%
------------------------------------------------------
TOTAL 323 42 87%
============================ 27 passed in 0.14s ============================

使用 --cov=cards 命令,覆盖范围将监控 cards 软件包。--cov=ch7 命令告诉覆盖系统监视 ch7 目录,也就是我们的测试所在的目录。

我们为什么要这么做?我们当然要对所有测试都进行覆盖,对吗?并不总是这样。在所有编程中,尤其是在编写测试代码时,一个常见的错误就是通过复制/粘贴/修改来添加新的测试函数。对于一个新的测试函数,我们可能会复制一个现有函数,将其粘贴为一个新函数,然后修改代码以满足新的测试用例。如果我们忘记更改函数名称,那么两个函数将具有相同的名称,并且只会运行文件中最后一个函数。将测试代码包含在覆盖源中,就能轻松解决测试名称重复的问题。

在大型测试模块中也会出现类似的问题,当我们忘记了所有函数的名称时,就会不小心将第二个测试函数命名为与前一个函数相同的名称。

第三个问题更为微妙。覆盖率可以将多个测试过程的报告合并在一起。例如,在持续集成中对不同硬件进行测试时就需要这样做。有些测试可能只针对某些硬件,而在其他硬件上会被跳过。如果包含测试,合并报告将帮助我们确保所有测试最终都至少在某些硬件上运行。它还有助于发现未使用的固定装置或固定装置中的死代码。

目录上的运行覆盖范围

我们一直在对已安装的软件包、卡片进行覆盖。 但 Python 世界不仅仅是构建可安装的包。 除了包之外,我们还可以要求覆盖范围关注目录和文件。 让我们看一下在目录上运行的覆盖范围。

在 ch9/some_code 目录中,我们有几个源代码模块和一个测试模块:

$ tree ch9/some_code
ch9/some_code
├── bar_module.py
├── foo_module.py
└── test_some_code.py

为了演示指向路径而不是包的覆盖,让我们留在顶级代码目录并从那里运行测试:

$ pytest --cov=ch9/some_code ch9/some_code/test_some_code.py
========================= test session starts ==========================
collected 2 items
ch9/some_code/test_some_code.py .. [100%]
---------- coverage: platform darwin, python 3.x.y -----------
Name Stmts Miss Cover
-----------------------------------------------------
ch9/some_code/bar_module.py 4 1 75%
ch9/some_code/foo_module.py 2 0 100%
ch9/some_code/test_some_code.py 6 0 100%
-----------------------------------------------------
TOTAL 12 1 92%
========================== 2 passed in 0.03s ===========================

我们使用 --cov=ch9/some_code 传递目录。 我们还可以直接从 ch9 目录运行所有内容:

$ cd /path/to/code/ch9
$ pytest --cov=some_code some_code/test_some_code.py

甚至只是:

$ pytest --cov=some_code some_code

因为 test_some_code.py 是唯一的测试文件,所以这两个 pytest 命令是等效的。

现在让我们看一个奇怪的极端情况:单个文件。

在单个文件上运行覆盖范围

很多可爱的单文件 Python 应用程序都需要一点测试覆盖。单文件应用程序(有时也称为脚本)通常不会打包或部署,而只是作为单文件共享。在这种情况下,将测试代码直接放到脚本中会很方便。

下面是一个小例子:

ch9/single_file.py
def foo():
    return "foo"


def bar():
    return "bar"


def baz():
    return "baz"


def main():
    print(foo(), baz())


if __name__ == "__main__":  # pragma: no cover
    main()

# test code, requires pytest


def test_foo():
    assert foo() == "foo"


def test_baz():
    assert baz() == "baz"


def test_main(capsys):
    main()
    captured = capsys.readouterr()
    assert captured.out == "foo baz\n"

这是运行时的样子:

$ cd /path/to/code/ch9
$ python single_file.py
foo baz

我们只需将 python 换成 pytest,就能在上面运行测试:

$ pytest single_file.py

但覆盖范围又如何呢? 如果这个脚本与一堆其他内容位于一个目录中,我们不能简单地将目录传递给覆盖范围,因为我们只想测量这个单个文件。

在这种情况下,我们将文件视为一个包,即使没有导入任何内容,并使用 --cov=single_file ,不带 .py 扩展名:

$ pytest --cov=single_file single_file.py
========================= test session starts ==========================
collected 3 items
single_file.py ... [100%]
---------- coverage: platform darwin, python 3.x.y -----------
Name Stmts Miss Cover
------------------------------------
single_file.py 16 1 94%
------------------------------------
TOTAL 16 1 94%
========================== 3 passed in 0.02s ===========================

pytest 的优点之一是我们甚至不需要导入 pytest。 要将测试添加到脚本中,我们只需添加它们即可。 但是,如果我们确实需要使用参数化或标记,您可以将导入粘贴到 if __name__ == '__main__' 块的 else 块中:

if __name__ == '__main__': # pragma: no cover
    main()
else:
    import pytest

这样,当您运行测试时它就在那里,但对于仅将您的脚本用作脚本的任何人来说不需要。

回顾

在本章中,我们使用 coverage.py 和 pytest-cov 来测量代码覆盖率,并使用了相当多的命令和选项。

要使用 pytest-cov 运行覆盖率,请使用:

  • pytest --cov=cards <test path> 用于运行简单报告

  • pytest --cov=cards --cov-report=term-missing <test path> 用于显示未运行的行

  • pytest --cov =cards --cov-report=html <test path> 生成 HTML 报告

对于单独运行覆盖范围,请使用:

  • coverage run --source=cards -m pytest <test path> 运行具有覆盖率的测试套件

  • coverage report 显示简单的终端报告

  • coverage report --show-missing 显示哪些线路未运行

  • coverage html 以生成 HTML 报告

即使通过 pytest --cov=…​ 运行了覆盖,也可以使用 coverage report 和 coverage html 运行不同的报告或生成 HTML。

--cov 和 --source 标志告诉 coverage 要监视哪些代码,可以是已安装软件包的名称,也可以是应用程序代码的路径。

coverage.py 和 pytest-cov 的内容远不止这些。(明白了吗? 哈!好了,我不说了。)请阅读这两个工具的相关文档,了解如何合并多次运行的覆盖率、分支覆盖率等更多信息。

练习

多做几次 coverage 练习,你就会知道它有多么简单和强大。我们先从简单的开始,然后通过这些练习让它变得更加精彩。

  1. single_file.py 的覆盖率显示为 94%。

    • 添加命令行标志以包含终端报告中缺少的行。

    • 奖励(Bonus):添加或更改测试以达到 100%。

  2. some_code 的示例显示覆盖率为 92%。

    • 生成 HTML 报告以查明缺少哪些代码。

    • 奖励(Bonus):添加或更改测试以达到 100%。

  3. 对于 Cards,我们在 api.py 中发现了一些与过滤 list 命令相关的缺失行。

    • 运行覆盖率报告并确保您在 api.py 中看到缺少的三行代码。

    • 通过编写三个新测试函数来扩展 ch7/test_list.py,以满足新的测试用例:

      • 包含所有者的列表,按所有者过滤

      • 包含状态的列表,按状态过滤

      • 包含所有者和状态的列表,按两者过滤

    • 运行覆盖率报告来看看你是否找到了缺失的行。

下一步

到目前为止,我们基本上忽略了 Cards 的用户界面——CLI。 在下一章中,我们将使用模拟为 CLI 编写测试。 您还将了解在测试期间使用和滥用模拟的各种方法。