第 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 命令:
@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":
def list_done_cards(self):
"""Return the 'done' cards."""
done_cards = self.list_cards(state="done")
现在让我们为 API 和 CLI 添加一些测试。
首先,API测试:
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 测试:
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 中的可选依赖项部分中定义:
[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 中进行了设置:
[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 命令。
-
新建一个虚拟环境,并以可编辑模式安装 Cards。
-
运行 pytest 并确保您看到本章中列出的相同失败。
-
使用 --lf 和 --lf -x 查看它们如何工作。
-
尝试 --stepwise 和 --stepwise-skip。 将它们都运行几次。 它们与 --lf 和 --lf -x 有什么不同?
-
使用 --pdb 在测试失败时打开 pdb。
-
使用 --lf --trace 在第一个失败测试开始时打开 pdb。
-
修复这两个错误并通过干净的测试运行进行验证。
-
在源代码或测试代码中的某处添加 breakpoint(),然后运行 pytest,既不使用 --pdb 也不使用 --trace。
-
(奖励)再次破坏一些东西并尝试使用 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
-
-