第 13 章 调试测试失败

测试失败时有发生。如果不发生,测试也就没什么用了。当测试失败时,我们要做的才是最重要的。当测试失败时,我们需要找出原因。可能是测试的问题,也可能是应用程序的问题。确定问题所在以及如何解决问题的过程也是类似的。

集成开发环境(IDE)和许多文本编辑器都内置了图形调试器。这些工具对调试非常有帮助,允许我们添加断点、逐步浏览代码、查看变量值等。不过,pytest 也提供了许多工具,可以帮助你更快地解决问题,而不必使用调试器。有时,集成开发环境也可能难以使用,例如在远程系统上调试代码或在一个 tox 环境中调试时。Python 内置了一个名为 pdb 的源代码调试器,它还提供了几个标志,使使用 pdb 进行调试变得简单快捷。

在本章中,我们将借助 pytest 标志和 pdb 来调试一些失败的代码。你可能会马上发现错误。太棒了 我们只是以 bug 为借口,来看看调试标记和 pytest 加 pdb 的集成。

我们需要一个失败的测试来进行调试。为此,我们将回到 Cards 项目—​这次是在开发者模式下—​添加一个功能和一些测试。

向 Cards 项目添加新功能

假设我们已经使用 Cards 一段时间了,现在已经完成了一些任务:

$ cards list

ID state owner summary
────────────────────────────────
1   done       some task
2   todo       another
3   done       a third

我们想在周末列出所有已完成的任务。 我们已经可以使用卡片列表来做到这一点,因为它有一些过滤功能:

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

$ cards list --state done
ID state owner summary
────────────────────────────────
1   done        some task
3   done        a third

这样可行。 但让我们添加一个卡片完成命令来为我们执行此过滤器。 为此,我们需要一个 CLI 命令:

ch13/cards_proj/src/cards/cli.py
@app.command("done")
def list_done_cards():
    """
    List 'done' cards in db.
    """
    with cards_db() as db:
        the_cards = db.list_done_cards()
        print_cards_list(the_cards)

此命令调用 API 方法 list_done_cards() 并打印结果。 list_done_cards() API 方法实际上只需要调用 list_cards() 并预填充 state="done":

ch13/cards_proj/src/cards/api.py
def list_done_cards(self):
    """Return the 'done' cards."""
    done_cards = self.list_cards(state="done")

现在让我们为 API 和 CLI 添加一些测试。

首先,API测试:

ch13/cards_proj/tests/api/test_list_done.py
import pytest


@pytest.mark.num_cards(10)
def test_list_done(cards_db):
    cards_db.finish(3)
    cards_db.finish(5)

    the_list = cards_db.list_done_cards()

    assert len(the_list) == 2
    for card in the_list:
        assert card.id in (3, 5)
        assert card.state == "done"

在这里,我们列出了 10 张卡片,并将其中两张标记为已完成。 list_done_cards() 的结果应该是具有正确索引且状态设置为“完成”的两张卡片的列表。 @pytest.mark.num_cards(10) 让 Faker 生成卡片的内容。

现在让我们添加 CLI 测试:

ch13/cards_proj/tests/cli/test_done.py
import cards

expected = """\

  ID   state   owner   summary    
 ──────────────────────────────── 
  1    done            some task  
  3    done            a third"""


def test_done(cards_db, cards_cli):
    cards_db.add_card(cards.Card("some task", state="done"))
    cards_db.add_card(cards.Card("another"))
    cards_db.add_card(cards.Card("a third", state="done"))
    output = cards_cli("done")
    assert output == expected

对于 CLI 测试,我们不能使用 Faker 数据,因为我们必须确切知道结果是什么。 相反,我们只需填写几张卡片并将其中几张卡片的状态设置为“完成”。

如果我们尝试在之前测试卡的同一虚拟环境中运行这些测试,它们将无法工作。 我们需要安装新版本的Cards。 因为我们正在编辑 Cards 源代码,所以我们需要以可编辑模式安装它。 我们将继续在新的虚拟环境中安装 cards_proj。

在可编辑模式下安装卡

在开发源代码和测试代码时,能够修改源代码并立即运行测试非常方便,而无需重建包并将其重新安装在我们的虚拟环境中。 以可编辑模式安装源代码正是我们需要完成此操作的事情,并且它是 pip 和 Flit 中内置的功能。

让我们启动一个新的虚拟环境:

$ cd /path/to/code/ch13
$ python3 -m venv venv
$ source venv/bin/activate
(venv) $ pip install -U pip
...
Successfully installed pip-21.3.x

现在在我们的新虚拟环境中,我们需要将 ./cards_proj 目录安装为本地可编辑包。 为此,我们需要 pip 版本 21.3.1 或更高版本,因此如果 pip 低于 21.3,请务必升级。

安装可编辑包就像 pip install -e ./package_dir_name 一样简单。 如果我们运行 pip install -e ./cards_proj 我们将以可编辑模式安装卡片。 然而,我们还想安装所有必要的开发工具,如 pytest、tox 等。

我们可以以可编辑模式安装卡,并使用可选依赖项一次性安装所有测试工具。

$ pip install -e "./cards_proj/[test]"

这是有效的,因为所有这些依赖项都已在 pyproject.toml 中的可选依赖项部分中定义:

ch13/cards_proj/pyproject.toml
[project.optional-dependencies]
test = [
    "pytest",
    "faker",
    "tox",
    "coverage",
    "pytest-cov",
]

现在让我们运行测试。 我们使用 --tb=no 来关闭回溯:

$ cd /path/to/code/ch13/cards_proj
$ pytest --tb=no
========================= test session starts ==========================
collected 55 items
tests/api/test_add.py ..... [ 9%]
...
tests/api/test_list_done.py .F [ 49%]
...
tests/cli/test_done.py .F [ 80%]
...
tests/cli/test_version.py . [100%]
======================= short test summary info ========================
FAILED tests/api/test_list_done.py::test_list_done - TypeError: objec...
FAILED tests/cli/test_done.py::test_done - AssertionError: assert '' ...
===================== 2 failed, 53 passed in 0.33s =====================

惊人的。 有一些失败,这正是我们想要的。 现在我们可以看看调试。

使用 pytest 标志进行调试

pytest 包含相当多对调试有用的命令行标志。 我们将使用其中一些来调试我们的测试失败。

用于选择运行哪些测试、运行顺序以及何时停止的标志:

  • -lf/--last-failed:仅运行最后失败的测试

  • -ff/--failed-first:运行所有测试,从最后一个失败的测试开始

  • -x/--exitfirst:第一次失败后停止测试会话

  • --maxfail=num:在 num 次失败后停止测试

  • -nf/--new-first:运行所有测试,按文件修改时间排序

  • --sw/--stepwise:在第一次失败时停止测试。 下次从上次失败的地方开始测试

  • --sw-skip/--stepwise-skip:与 --sw 相同,但跳过第一次失败

控制 pytest 输出的标志:

  • -v/--verbose:显示所有测试名称,通过或失败

  • --tb=[auto/long/short/line/native/no]:控制回溯样式

  • -l/--showlocals:在堆栈跟踪旁边显示局部变量

启动命令行调试器的标志:

  • --pdb:在故障点启动交互式调试会话

  • --trace:运行每个测试时立即启动 pdb 源代码调试器

  • --pdbcls:使用 pdb 的替代方案,例如带有 --pdbcls=IPython.terminal.debugger:TerminalPdb 的 IPython 调试器

对于所有这些描述,“失败” 是指失败的断言或在我们的源代码或测试代码(包括固定装置)中发现的任何其他未捕获的异常。

重新运行失败的测试

让我们通过确保再次运行测试时失败来开始调试。 我们将使用 --lf 仅重新运行失败,并使用 --tb=no 隐藏回溯,因为我们还没有准备好:

$ pytest --lf --tb=no
========================= test session starts ==========================
collected 27 items / 25 deselected / 2 selected
run-last-failure: re-run previous 2 failures (skipped 13 files)
tests/api/test_list_done.py F [ 50%]
tests/cli/test_done.py F [100%]
======================= short test summary info ========================
FAILED tests/api/test_list_done.py::test_list_done - TypeError: objec...
FAILED tests/cli/test_done.py::test_done - AssertionError: assert '' ...
=================== 2 failed, 25 deselected in 0.10s ===================

伟大的。 我们知道我们可以重现失败。 我们将从调试第一个失败开始。

让我们只运行第一个失败的测试,失败后停止,然后查看回溯:

$ pytest --lf -x
========================= test session starts ==========================
collected 27 items / 25 deselected / 2 selected
run-last-failure: re-run previous 2 failures (skipped 13 files)
tests/api/test_list_done.py F
=============================== FAILURES ===============================
____________________________ test_list_done ____________________________
cards_db = <cards.api.CardsDB object at 0x7fabab5288b0>
    @pytest.mark.num_cards(10)
    def test_list_done(cards_db):
        cards_db.finish(3)
        cards_db.finish(5)

        the_list = cards_db.list_done_cards()
>       assert len(the_list) == 2
➤ E    TypeError: object of type 'NoneType' has no len()

tests/api/test_list_done.py:10: TypeError
======================= short test summary info ========================
FAILED tests/api/test_list_done.py::test_list_done - TypeError: objec...
!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!
=================== 1 failed, 25 deselected in 0.18s ===================

错误 TypeError: object of type 'NoneType' has no len() 告诉我们 the_list 是 None。 这不好。 我们期望它是 Card 对象的列表。 即使没有 “完成” 卡,它也应该是一个空列表,而不是 “无”。 实际上,这可能是一个很好的测试,可以检查一切是否正常工作,没有 “完成” 卡。 专注于手头的问题,让我们回到调试。

为了确保我们理解问题,我们可以使用 -l/--showlocals 再次运行相同的测试。 我们不需要再次完整的回溯,因此我们可以使用 --tb=short 来缩短它:

$ pytest --lf -x -l --tb=short
========================= test session starts ==========================
collected 27 items / 25 deselected / 2 selected
run-last-failure: re-run previous 2 failures (skipped 13 files)
tests/api/test_list_done.py F
=============================== FAILURES ===============================
____________________________ test_list_done ____________________________
tests/api/test_list_done.py:10: in test_list_done
    assert len(the_list) == 2
E   TypeError: object of type 'NoneType' has no len()
        cards_db = <cards.api.CardsDB object at 0x7f884a4e8850>
➤       the_list = None
======================= short test summary info ========================
FAILED tests/api/test_list_done.py::test_list_done - TypeError: objec...
!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!
=================== 1 failed, 25 deselected in 0.18s ===================

是的。 the_list = None。 -l/--showlocals 通常非常有用,有时足以完全调试测试失败。 更重要的是,-l/--showlocals 的存在训练了我在测试中使用大量中间变量。 当测试失败时它们会派上用场。

现在我们知道在这种情况下,list_done_cards() 返回 None。 但我们不知道为什么。 在测试过程中,我们将使用 pdb 在 list_done_cards() 内部进行调试。

使用 pdb 进行调试

pdb 代表 “Python 调试器”,是 Python 标准库的一部分,因此我们不需要安装任何东西即可使用它。 我们将启动并运行 pdb,然后查看 pdb 中的一些最有用的命令。

您可以通过几种不同的方式从 pytest 启动 pdb:

  • 将 breakpoint() 调用添加到测试代码或应用程序代码。当 pytest 运行遇到 breakpoint() 函数调用时,它将在那里停止并启动 pdb。

  • 使用 --pdb 标志。使用 --pdb,pytest 将在失败时停止。在我们的例子中,这将位于 assert len(the_list) == 2 行。

  • 使用 --trace 标志。使用 --trace,pytest 将在每次测试开始时停止。

就我们的目的而言,结合 --lf 和 --trace 将完美地工作。该组合将告诉 pytest 重新运行失败的测试,并在调用 list_done_cards() 之前停止在 test_list_done() 的开头:

$ pytest --lf --trace
========================= test session starts ==========================
collected 27 items / 25 deselected / 2 selected
run-last-failure: re-run previous 2 failures (skipped 13 files)
tests/api/test_list_done.py
>>>>>>>>>>>>>>>> PDB runcall (IO-capturing turned off) >>>>>>>>>>>>>>>>>
> /path/to/code/ch13/cards_proj/tests/api/test_list_done.py(5)test_list_done()
-> cards_db.finish(3)
(Pdb)

以下是 pdb 识别的常用命令。完整列表位于 pdb 文档中。

元命令:

  • h(elp):打印命令列表

  • h(elp) command:打印命令的帮助

  • q(uit):退出 pdb

查看您所在的位置:

  • l(ist):列出当前行周围的 11 行。再次使用它会列出接下来的 11 行,依此类推。

  • l(ist).:与上面相同,但带有一个点。列出当前行周围的 11 行。如果您使用过 l(list) 几次并且丢失了当前位置,那么这会很方便

  • l(ist)first,last:列出一组特定的行

  • ll:列出当前函数的所有源代码

  • w(here):打印堆栈跟踪

查看值:

  • p(rint) expr:计算 expr 并打印该值

  • pp expr:与 p(rint) expr 相同,但使用 pprint 模块中的漂亮打印。 非常适合结构

  • a(rgs):打印当前函数的参数列表

执行命令:

  • s(tep):执行源代码中的当前行并单步执行到下一行,即使它位于函数内部

  • n(ext):执行当前函数中的当前行并跳转到下一行

  • r(eturn):继续执行,直到当前函数返回

  • c(ontinue):继续直到下一个断点。 与 --trace 一起使用时,将持续到下一个测试开始

  • unt(il) lineno:继续直到给定的行号

继续调试我们的测试,我们将使用 ll 列出当前函数:

(Pdb) ll
3   @pytest.mark.num_cards(10)
4   def test_list_done(cards_db):
5 ->    cards_db.finish(3)
6       cards_db.finish(5)
7
8       the_list = cards_db.list_done_cards()
9
10      assert len(the_list) == 2
11      for card in the_list:
12          assert card.id in (3, 5)
13          assert card.state == "done"

-> 在运行之前向我们显示当前行。

我们可以在调用 list_done_cards() 之前使用 until 8 来中断,如下所示:

(Pdb) until 8
> /path/to/code/ch13/cards_proj/tests/api/test_list_done.py(8)test_list_done()
-> the_list = cards_db.list_done_cards()

然后让我们进入该函数:

(Pdb) step
--Call--
> /path/to/code/ch13/cards_proj/src/cards/api.py(82)list_done_cards()
-> def list_done_cards(self):

让我们再次使用 ll 来查看整个函数:

(Pdb) ll
82 ->   def list_done_cards(self):
83           """Return the 'done' cards."""
84           done_cards = self.list_cards(state='done')

现在让我们继续直到该函数返回之前:

(Pdb) return
--Return--
> /path/to/code/ch13/cards_proj/src/cards/api.py(84)list_done_cards()->None
-> done_cards = self.list_cards(state='done')
(Pdb) ll
82      def list_done_cards(self):
83          """Return the 'done' cards."""
84 ->       done_cards = self.list_cards(state='done')

我们可以用 p 或 pp 查看 did_cards 的值:

(Pdb) pp done_cards
[Card(summary='Line for PM identify decade.',
owner='Russell', state='done', id=3),
Card(summary='Director season industry the describe.',
owner='Cody', state='done', id=5)]

这看起来不错,但我想我看到了问题。如果我们继续进行调用测试并检查返回值,我们可以双重确定:

(Pdb) step
> /path/to/code/ch13/cards_proj/tests/api/test_list_done.py(10)test_list_done()
-> assert len(the_list) == 2
(Pdb) ll
3      @pytest.mark.num_cards(10)
4      def test_list_done(cards_db):
5           cards_db.finish(3)
6           cards_db.finish(5)
7
8           the_list = cards_db.list_done_cards()
9
10 ->       assert len(the_list) == 2
11          for card in the_list:
12              assert card.id in (3, 5)
13              assert card.state == "done"
(Pdb) pp the_list
None

现在很清楚了。 我们在 list_done_cards() 的 did_cards 变量中拥有正确的列表。 但是,该值不会返回。 因为如果没有 return 语句,Python 中的默认返回值为 None,这就是在 test_list_done() 中分配给 the_list 的值。

如果我们停止调试器,将 return done_cards 添加到 list_done_cards(),然后重新运行失败的测试,我们可以看看是否修复了它:

(Pdb) exit
!!!!!!!!!!!!!!! _pytest.outcomes.Exit: Quitting debugger !!!!!!!!!!!!!!!
================== 25 deselected in 521.22s (0:08:41) ==================
$ pytest --lf -x -v --tb=no
========================= test session starts ==========================
collected 27 items / 25 deselected / 2 selected
run-last-failure: re-run previous 2 failures (skipped 13 files)
tests/api/test_list_done.py::test_list_done PASSED [ 50%]
tests/cli/test_done.py::test_done FAILED [100%]
======================= short test summary info ========================
FAILED tests/cli/test_done.py::test_done - AssertionError: assert ' ...
!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!
============== 1 failed, 1 passed, 25 deselected in 0.10s ==============

太棒了。我们修复了一个错误。还有一个。

结合 pdb 和 tox

为了调试下一个测试失败,我们将结合 tox 和 pdb。 为此,我们必须确保可以通过 tox 将参数传递给 pytest。 这是通过 tox 的 {posargs} 功能完成的,该功能在 【通过 tox 传递 pytest 参数(第 158 页)】中进行了讨论。

我们已经在卡片的 tox.ini 中进行了设置:

ch13/cards_proj/tox.ini
[tox]
envlist = py39, py310
isolated_build = True
skip_missing_interpreters = True

[testenv]
deps =
  pytest
  faker
  pytest-cov
commands = pytest --cov=cards --cov=tests --cov-fail-under=100 {posargs}

我们想运行 Python 3.10 环境,并在测试失败时启动调试器。我们可以使用 -e py310 运行一次,然后使用 -e py310 — --lf --trace 在第一个失败测试的入口点停止。

相反,我们只运行一次并使用 -e py310 — --pdb --no-cov 在故障点停止。 (--no-cov 用于关闭覆盖率报告。)

$ tox -e py310 -- --pdb --no-cov
...
py310 run-test: commands[0] | pytest --cov=cards --cov=tests
--cov-fail-under=100 --pdb --no-cov
========================= test session starts ==========================
...
collected 53 items
tests/api/test_add.py ..... [ 9%]
tests/api/test_config.py . [ 11%]
...
tests/cli/test_delete.py . [ 77%]
tests/cli/test_done.py F
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> traceback >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
...
> assert output == expected
...
tests/cli/test_done.py:15: AssertionError
>>>>>>>>>>>>>>>>>>>>>>>>>>>>> entering PDB >>>>>>>>>>>>>>>>>>>>>>>>>>>>>
>>>>>>>>>>>>>> PDB post_mortem (IO-capturing turned off) >>>>>>>>>>>>>>>
> /path/to/code/ch13/cards_proj/tests/cli/test_done.py(15)test_done()
-> assert output == expected
(Pdb) ll
10 def test_done(cards_db, cards_cli):
11      cards_db.add_card(cards.Card("some task", state="done"))
12      cards_db.add_card(cards.Card("another"))
13      cards_db.add_card(cards.Card("a third", state="done"))
14      output = cards_cli("done")
15 ->   assert output == expected

这会让我们进入 pdb,就在断言失败的地方。

我们可以使用 pp 查看输出(output)和预期(expected)变量:

(Pdb) pp output
➤ ('                              \n'
' ID   state   owner   summary     \n'
' ──────────────────────────────── \n'
' 1    done              some task \n'
' 3    done              a third')
(Pdb) pp expected
➤ ('\n'
' ID   state   owner   summary     \n'
' ──────────────────────────────── \n'
' 1    done              some task \n'
' 3    done              a third')

现在我们可以看到问题所在了。预期输出以包含单个换行符 “\n” 的行开头。实际输出在新行之前包含一堆空格。仅通过回溯(甚至在 IDE 中)很难发现此问题。 有了 pdb,就不难发现了。

我们可以将这些空格添加到测试中,并在一次测试失败的情况下重新运行 tox 环境:

$ tox -e py310 -- --lf --tb=no --no-cov -v
...
py310 run-test: commands[0] | pytest --cov=cards --cov=tests
--cov-fail-under=100 --lf --tb=no --no-cov -v
========================= test session starts ==========================
...
tests/cli/test_done.py::test_done PASSED [100%]
=================== 1 passed, 41 deselected in 0.11s ===================
_______________________________ summary ________________________________
py310: commands succeeded
congratulations :)

为了更好地衡量,重新运行整个过程:

$ tox
...
Required test coverage of 100% reached. Total coverage: 100.00%
========================== 53 passed in 0.53s ==========================
_______________________________ summary ________________________________
py310: commands succeeded
py310: commands succeeded
congratulations :)

呜呼!缺陷已修复。

回顾

我们介绍了许多使用命令行标志、pdb 和 tox 调试 Python 包的技术:

  • 我们使用 pip install -e ./cards_proj 安装了 Cards 的可编辑版本。

  • 我们使用了许多 pytest 标志进行调试。使用 pytest 标志进行调试,第 183 页有有用标志的列表。

  • 我们使用 pdb 来调试测试。pdb 命令的子集位于第 186 页的使用 pdb 进行调试。

  • 我们将 tox、pytest 和 pdb 组合起来,在 tox 环境中调试失败的测试。

练习

本章代码下载中包含的代码文件没有修复。他们只是有损坏的代码。即使您计划使用 IDE 进行大部分调试,我也鼓励您尝试本章中的调试技术,以帮助您了解如何使用 flags 和 pdb 命令。

  1. 新建一个虚拟环境,并以可编辑模式安装 Cards。

  2. 运行 pytest 并确保您看到本章中列出的相同失败。

  3. 使用 --lf 和 --lf -x 查看它们如何工作。

  4. 尝试 --stepwise 和 --stepwise-skip。 将它们都运行几次。 它们与 --lf 和 --lf -x 有什么不同?

  5. 使用 --pdb 在测试失败时打开 pdb。

  6. 使用 --lf --trace 在第一个失败测试开始时打开 pdb。

  7. 修复这两个错误并通过干净的测试运行进行验证。

  8. 在源代码或测试代码中的某处添加 breakpoint(),然后运行 pytest,既不使用 --pdb 也不使用 --trace。

  9. (奖励)再次破坏一些东西并尝试使用 IPython 进行调试。(IPython3 是 Jupyter 项目的一部分。请参阅各自的文档以获取更多信息。)

    • 使用 pip install ipython 安装 IPython。

    • 您可以使用以下命令运行它:

      • pytest --lf --trace --pdbcls=IPython.terminal.debugger:TerminalPdb

      • pytest --pdb --pdbcls=IPython.terminal.debugger:TerminalPdb

      • 将 breakpoint() 放在代码中的某个位置并运行 pytest --pdbcls=IPython.terminal.debugger:TerminalPdb

下一步

本书的下一部分旨在帮助你提高编写和运行测试的效率。很多常见的测试问题已经被别人解决了,并打包成了 pytest 插件。我们将在下一章介绍一些第三方插件。在第三方插件之后,我们将在 第 15 章 "构建插件"(Building Plugins)(第 205 页)中构建我们自己的插件。最后,我们将在 第 16 章 "高级参数化"(Advanced Parametrization)(第 221 页)中重温参数化,并学习一些高级技术。