简介
在本章中,你将学习如何在测试Python包的情况下编写测试函数。如果你用 pytest 来测试 Python 包以外的东西,本章的大部分内容仍然适用。
我们将为一个简单的任务跟踪命令行程序写测试,这个程序叫做 Cards。我们将研究如何在测试中使用 assert,测试如何处理意外的异常,以及如何测试预期的异常。
最终,我们会有很多的测试。因此,我们将研究如何将测试组织成类、模块和目录。
安装示例应用
我们编写的测试代码需要能够运行应用程序的代码。应用程序代码 "是我们正在验证的代码,它有很多名字。你可能会听到它被称为生产代码、应用程序、被测代码(CUT:code under test )、被测系统(SUT:system under test)、被测设备(DUT:device under test),我们将使用 "应用代码 "这个术语。
测试代码 "是我们为了测试应用代码而编写的代码。具有讽刺意味的是,"测试代码 "是相当明确的,除了 "测试代码 "之外,没有太多的名字。
在我们的例子中,Cards 项目就是应用程序代码。它是一个可安装的 Python 包,我们需要安装它以测试它。安装它也将允许我们在命令行上玩弄 Cards 项目。如果你要测试的代码不是可以安装的 Python 包,你就必须用其他方法让你的测试看到你的代码。(一些替代方法在第 12 章 测试脚本和应用程序中讨论。)
- 安装
$ pip install cards_proj/
Processing d:\code\pytest_quick\cards_proj
Installing build dependencies: started
Installing build dependencies: finished with status 'done'
Getting requirements to build wheel: started
Getting requirements to build wheel: finished with status 'done'
Preparing metadata (pyproject.toml): started
Preparing metadata (pyproject.toml): finished with status 'done'
Collecting typer==0.3.2
Downloading typer-0.3.2-py3-none-any.whl (21 kB)
Collecting tinydb==4.5.1
Downloading tinydb-4.5.1-py3-none-any.whl (23 kB)
Collecting rich==10.7.0
Downloading rich-10.7.0-py3-none-any.whl (209 kB)
------------------------------------ 209.6/209.6 kB 706.8 kB/s eta 0:00:00
Requirement already satisfied: colorama<0.5.0,>=0.4.0 in d:\programdata\anaconda3\lib\site-packages (from rich==10.7.0->cards==1.0.0) (0.4.5)
Collecting commonmark<0.10.0,>=0.9.0
Downloading commonmark-0.9.1-py2.py3-none-any.whl (51 kB)
---------------------------------------- 51.1/51.1 kB 1.3 MB/s eta 0:00:00
Requirement already satisfied: pygments<3.0.0,>=2.6.0 in d:\programdata\anaconda3\lib\site-packages (from rich==10.7.0->cards==1.0.0) (2.11.2)
Collecting click<7.2.0,>=7.1.1
Downloading click-7.1.2-py2.py3-none-any.whl (82 kB)
---------------------------------------- 82.8/82.8 kB 1.5 MB/s eta 0:00:00
Building wheels for collected packages: cards
Building wheel for cards (pyproject.toml): started
Building wheel for cards (pyproject.toml): finished with status 'done'
Created wheel for cards: filename=cards-1.0.0-py3-none-any.whl size=5011 sha256=61c1601a2053682eeccbafcf120c2606604175b8730f7681ca5a0a521b83c6c5
Stored in directory: D:\Temp\pip-ephem-wheel-cache-5y22fhi5\wheels\0a\19\39\ed9dee4c4704cf05c72119386093ac231b9fe1c12faf5da0cc
Successfully built cards
Installing collected packages: commonmark, tinydb, rich, click, typer, cards
Attempting uninstall: click
Found existing installation: click 8.0.4
Uninstalling click-8.0.4:
Successfully uninstalled click-8.0.4
ERROR: pip's dependency resolver does not currently take into account all the packages that are installed. This behaviour is the source of the following dependency conflicts.
black 22.6.0 requires click>=8.0.0, but you have click 7.1.2 which is incompatible.
Successfully installed cards-1.0.0 click-7.1.2 commonmark-0.9.1 rich-10.7.0 tinydb-4.5.1 typer-0.3.2
- 演示
$cards add "联系作者:钉钉、抖音或微信pythontesting" --owner Andrew
$cards
ID state owner summary
───────────────────────────────────────────────────────────────
1 todo Andrew 联系作者:钉钉、抖音或微信pythontesting
$cards add "抢火车票" --owner Bob
$cards
ID state owner summary
───────────────────────────────────────────────────────────────
1 todo Andrew 联系作者:钉钉、抖音或微信pythontesting
2 todo Bob 抢火车票
$cards update 2 --owner Ted
$cards
ID state owner summary
───────────────────────────────────────────────────────────────
1 todo Andrew 联系作者:钉钉、抖音或微信pythontesting
2 todo Ted 抢火车票
$cards start 1
$cards finish 1
$cards
ID state owner summary
───────────────────────────────────────────────────────────────
1 done Andrew 联系作者:钉钉、抖音或微信pythontesting
2 todo Ted 抢火车票
$cards add "testing" --owner Bob
$cards
ID state owner summary
───────────────────────────────────────────────────────────────
1 done Andrew 联系作者:钉钉、抖音或微信pythontesting
2 todo Ted 抢火车票
3 todo Bob testing
$cards delete 3
$cards
ID state owner summary
───────────────────────────────────────────────────────────────
1 done Andrew 联系作者:钉钉、抖音或微信pythontesting
2 todo Ted 抢火车票
cards可以用添加、更新、开始、结束和删除等动作来操作,而运行cards会列出卡片。
很好。现在我们准备写一些测试了。
了解被测系统
Cards的源代码被分成三层。CLI、 API, 和 DB. CLI处理与用户的交互。CLI调用API,它处理应用程序的大部分逻辑。API调用DB层(数据库),用于保存和检索应用数据。我们将在《软件架构》中更多地了解卡片的结构。
有一个数据结构用来在ClI和API之间传递信息,这个数据类叫做Card。
@dataclass
class Card:
summary: str = None
owner: str = None
state: str = "todo"
id: int = field(default=None, compare=False)
@classmethod
def from_dict(cls, d):
return Card(**d)
def to_dict(self):
return asdict(self)
Data class在 3.7 版本中被添加到 Python 中,但它们对一些人来说可能仍然很陌生。卡片结构有三个字符串字段:摘要、所有者和状态,以及一个整数字段:ID。摘要、所有者和id字段默认为无。状态字段默认为 "todo"。id字段也使用字段方法来利用compare=False,这应该是告诉代码,当比较两个Card对象是否相等时,不要使用id字段。我们一定会测试这一点,以及其他方面。为了方便和清晰起见,还增加了几个方法:from_dict和to_dict,因为Card(**d)或dataclasses.asdict()不是很容易读。
当面对新的数据结构时,快速测试往往是很有帮助的,这样你就可以了解数据结构是如何工作的。所以,让我们从一些测试开始,验证我们对这个东西应该如何工作的理解。
ch2/test_card.py
from cards import Card
def test_field_access():
c = Card("something", "brian", "todo", 123)
assert c.summary == "something"
assert c.owner == "brian"
assert c.state == "todo"
assert c.id == 123
def test_defaults():
c = Card()
assert c.summary is None
assert c.owner is None
assert c.state == "todo"
assert c.id is None
def test_equality():
c1 = Card("something", "brian", "todo", 123)
c2 = Card("something", "brian", "todo", 123)
assert c1 == c2
def test_equality_with_diff_ids():
c1 = Card("something", "brian", "todo", 123)
c2 = Card("something", "brian", "todo", 4567)
assert c1 == c2
def test_inequality():
c1 = Card("something", "brian", "todo", 123)
c2 = Card("completely different", "okken", "done", 123)
assert c1 != c2
def test_from_dict():
c1 = Card("something", "brian", "todo", 123)
c2_dict = {
"summary": "something",
"owner": "brian",
"state": "todo",
"id": 123,
}
c2 = Card.from_dict(c2_dict)
assert c1 == c2
def test_to_dict():
c1 = Card("something", "brian", "todo", 123)
c2 = c1.to_dict()
c2_expected = {
"summary": "something",
"owner": "brian",
"state": "todo",
"id": 123,
}
assert c2 == c2_expected
- 执行
$ pytest test_card.py
============================= test session starts =============================
platform win32 -- Python 3.9.13, pytest-7.1.2, pluggy-1.0.0
rootdir: D:\code\pytest_quick, configfile: pytest.ini
plugins: allure-pytest-2.12.0, Faker-4.18.0, tep-0.8.2, anyio-3.5.0
collected 7 items
test_card.py ....... [100%]
============================== 7 passed in 0.14s ==============================
这些测试的重点是检查对架构的理解,并可能为其他人甚至为未来的我记录这些知识。这种检查我自己的理解的用法,以及真正把测试当作玩弄应用程序代码的小游乐场,是超级强大的,我认为如果更多的人从这种心态开始,他们会更喜欢测试。
还要注意的是,所有这些测试都使用普通的断言语句。
断言assert
当你写测试函数时,普通的Python assert语句是你沟通测试失败的主要工具。这在pytest中的简单性是非常好的。这也是促使很多开发者使用pytest而不是其他框架的原因。
如果你使用过任何其他测试框架,你可能已经看到了各种断言辅助函数。例如,下面是unittest的一些断言形式和断言辅助函数的列表。
在pytest中,你可以对任何表达式使用assert <expression>。如果该表达式转换为bool的话,会评估为False,测试会失败。
pytest包括 "断言重写 "的功能,它可以拦截断言调用,并将其替换为可以告诉你更多关于断言失败原因的内容。让我们通过查看一个断言的失败来看看这种重写有多大帮助。
ch2/test_card_fail.py
def test_equality_fail():
c1 = Card("sit there", "brian")
c2 = Card("do something", "okken")
assert c1 == c2
这个测试会失败,但有趣的是追踪信息。
$ pytest test_card_fail.py
============================= test session starts =============================
platform win32 -- Python 3.9.13, pytest-7.1.2, pluggy-1.0.0
rootdir: D:\code\pytest_quick, configfile: pytest.ini
plugins: allure-pytest-2.12.0, Faker-4.18.0, tep-0.8.2, anyio-3.5.0
collected 1 item
test_card_fail.py F [100%]
================================== FAILURES ===================================
_____________________________ test_equality_fail ______________________________
def test_equality_fail():
c1 = Card("sit there", "brian")
c2 = Card("do something", "okken")
> assert c1 == c2
E AssertionError: assert Card(summary=...odo', id=None) == Card(summary=...odo', id=None)
E
E Omitting 1 identical items, use -vv to show
E Differing attributes:
E ['summary', 'owner']
E
E Drill down into differing attribute summary:
E summary: 'sit there' != 'do something'...
E
E ...Full output truncated (8 lines hidden), use '-vv' to show
test_card_fail.py:7: AssertionError
=========================== short test summary info ===========================
FAILED test_card_fail.py::test_equality_fail - AssertionError: assert Card(su...
============================== 1 failed in 0.22s ==============================
这是很大的信息量。对于每个失败的测试,失败的确切行被显示出来,并有一个 > 指向失败。E行显示了关于断言失败的额外信息,以帮助你找出出错的原因。
我故意在test_equality_fail()中放了两个不匹配,但在前面的代码中只显示了第一个。让我们按照错误信息中的建议,用-vv标志再试一下。
$ pytest -vv test_card_fail.py
============================= test session starts =============================
platform win32 -- Python 3.9.13, pytest-7.1.2, pluggy-1.0.0 -- D:\ProgramData\Anaconda3\python.exe
cachedir: .pytest_cache
rootdir: D:\code\pytest_quick, configfile: pytest.ini
plugins: allure-pytest-2.12.0, Faker-4.18.0, tep-0.8.2, anyio-3.5.0
collecting ... collected 1 item
test_card_fail.py::test_equality_fail FAILED [100%]
================================== FAILURES ===================================
_____________________________ test_equality_fail ______________________________
def test_equality_fail():
c1 = Card("sit there", "brian")
c2 = Card("do something", "okken")
> assert c1 == c2
E AssertionError: assert Card(summary='sit there', owner='brian', state='todo', id=None) == Card(summary='do something', owner='okken', state='todo', id=None)
E
E Matching attributes:
E ['state']
E Differing attributes:
E ['summary', 'owner']
E
E Drill down into differing attribute summary:
E summary: 'sit there' != 'do something'
E - do something
E + sit there
E
E Drill down into differing attribute owner:
E owner: 'brian' != 'okken'
E - okken
E + brian
test_card_fail.py:7: AssertionError
=========================== short test summary info ===========================
FAILED test_card_fail.py::test_equality_fail - AssertionError: assert Card(su...
============================== 1 failed in 0.21s ==============================
pytest明确列出了哪些属性是匹配的,哪些是不匹配的,并强调了确切的不匹配。
在pytest.org网站上可以找到更多种类的断言语句,并有很好的跟踪调试信息。
断言失败是测试代码导致测试失败的主要方式。然而,这并不是唯一的方式。
通过pytest.fail()和异常失败用例
如果有任何未捕获的异常,测试就会失败。这可能发生在
- 断言语句失败,这将引发AssertionError异常。
- 测试代码调用pytest.fail(),这将引发一个异常,或任何其他异常被引发。
虽然任何异常都可以使测试失败,但我更喜欢使用assert。在极少数情况下,如果assert不合适,可以使用pytest.fail()。
下面是使用pytest的fail()函数来明确地使测试失败的例子。
ch2/test_alt_fail.py
$ cat test_alt_fail.py
import pytest
from cards import Card
def test_with_fail():
c1 = Card("sit there", "brian")
c2 = Card("do something", "okken")
if c1 != c2:
pytest.fail("they don't match")
- 执行
$ pytest test_alt_fail.py
============================= test session starts =============================
platform win32 -- Python 3.9.13, pytest-7.1.2, pluggy-1.0.0
rootdir: D:\code\pytest_quick, configfile: pytest.ini
plugins: allure-pytest-2.12.0, Faker-4.18.0, tep-0.8.2, anyio-3.5.0
collected 1 item
test_alt_fail.py F [100%]
================================== FAILURES ===================================
_______________________________ test_with_fail ________________________________
def test_with_fail():
c1 = Card("sit there", "brian")
c2 = Card("do something", "okken")
if c1 != c2:
> pytest.fail("they don't match")
E Failed: they don't match
test_alt_fail.py:9: Failed
=========================== short test summary info ===========================
FAILED test_alt_fail.py::test_with_fail - Failed: they don't match
============================== 1 failed in 0.21s ==============================
当调用pytest.fail()或直接引发异常时,我们不会得到pytest提供的奇妙的断言重写。然而,也有合理的时候使用pytest.fail(),比如在断言帮助器中。
编写断言助手函数
断言助手是用来包装复杂的断言检查的函数。举个例子,Cards数据类的设置是这样的:两张ID不同的卡片仍然会报告相等。如果我们想有更严格的检查,我们可以写一个叫assert_identical的辅助函数,像这样。
from cards import Card
import pytest
def assert_identical(c1: Card, c2: Card):
__tracebackhide__ = True
assert c1 == c2
if c1.id != c2.id:
pytest.fail(f"id's don't match. {c1.id} != {c2.id}")
def test_identical():
c1 = Card("foo", id=123)
c2 = Card("foo", id=123)
assert_identical(c1, c2)
def test_identical_fail():
c1 = Card("foo", id=123)
c2 = Card("foo", id=456)
assert_identical(c1, c2)
assert_identical函数设置tracebackhide = True。这是可选的。其效果是,失败的测试不会在回溯中包括这个函数。然后,正常的 assert c1 == c2 被用来检查除 ID 之外的所有内容是否相等。
最后,检查ID,如果它们不相等,就用pytest.fail()来拒绝测试,并希望有一个有用的信息。
让我们看看运行时是什么样子的。
$ pytest test_helper.py
============================= test session starts =============================
platform win32 -- Python 3.9.13, pytest-7.1.2, pluggy-1.0.0
rootdir: D:\code\pytest_quick, configfile: pytest.ini
plugins: allure-pytest-2.12.0, Faker-4.18.0, tep-0.8.2, anyio-3.5.0
collected 2 items
test_helper.py .F [100%]
================================== FAILURES ===================================
_____________________________ test_identical_fail _____________________________
def test_identical_fail():
c1 = Card("foo", id=123)
c2 = Card("foo", id=456)
> assert_identical(c1, c2)
E Failed: id's don't match. 123 != 456
test_helper.py:21: Failed
=========================== short test summary info ===========================
FAILED test_helper.py::test_identical_fail - Failed: id's don't match. 123 !=...
========================= 1 failed, 1 passed in 0.21s =========================
如果我们没有加入 tracebackhide = True,assert_identical 代码就会被包含在跟踪回溯中,在这种情况下,不会增加任何清晰度。我也可以使用 assert c1.id == c2.id, "id's don't match." 达到同样的效果,但我想展示一个使用 pytest.fail() 的例子。
注意,断言重写只适用于conftest.py文件和测试文件。
测试预期的异常
我们已经研究了任何异常如何导致测试失败。但是如果你正在测试的一段代码应该引发一个异常呢?你如何测试呢?
你可以使用 pytest.raises() 来测试预期的异常。
卡片API有CardsDB类,需要一个路径参数。如果我们不传入路径会发生什么?让我们来试试。
import cards
def test_no_path_fail():
cards.CardsDB()
- 执行
$ pytest --tb=short test_experiment.py
============================= test session starts =============================
platform win32 -- Python 3.9.13, pytest-7.1.2, pluggy-1.0.0
rootdir: D:\code\pytest_quick, configfile: pytest.ini
plugins: allure-pytest-2.12.0, Faker-4.18.0, tep-0.8.2, anyio-3.5.0
collected 1 item
test_experiment.py F [100%]
================================== FAILURES ===================================
______________________________ test_no_path_fail ______________________________
test_experiment.py:5: in test_no_path_fail
cards.CardsDB()
E TypeError: __init__() missing 1 required positional argument: 'db_path'
=========================== short test summary info ===========================
FAILED test_experiment.py::test_no_path_fail - TypeError: __init__() missing ...
============================== 1 failed in 0.20s ==============================
这里我使用了 --tb=short 较短的回溯格式,因为我们不需要看到完整的回溯来发现哪个异常被引发。
TypeError异常是合理的,因为错误发生在试图初始化自定义的CardsDB类型时。我们可以写一个测试来确保这个异常被抛出,像这样。
import pytest
import cards
def test_no_path_raises():
with pytest.raises(TypeError):
cards.CardsDB()
def test_raises_with_info():
match_regex = "missing 1 .* positional argument"
with pytest.raises(TypeError, match=match_regex):
cards.CardsDB()
def test_raises_with_info_alt():
with pytest.raises(TypeError) as exc_info:
cards.CardsDB()
expected = "missing 1 required positional argument"
assert expected in str(exc_info.value)
- 执行
$ pytest test_exceptions.py
============================= test session starts =============================
platform win32 -- Python 3.9.13, pytest-7.1.2, pluggy-1.0.0
rootdir: D:\code\pytest_quick, configfile: pytest.ini
plugins: allure-pytest-2.12.0, Faker-4.18.0, tep-0.8.2, anyio-3.5.0
collected 3 items
test_exceptions.py ... [100%]
============================== 3 passed in 0.15s ==============================
with pytest.raises(TypeError): 的下一个代码块中的任何内容都应该引发一个TypeError异常。如果没有产生异常,测试就会失败。如果测试引发了一个不同的异常,则失败。
我们刚刚在test_no_path_raises()中检查了异常的类型。我们还可以检查以确保消息是正确的,或者异常的任何其他方面,比如额外的参数。
匹配参数需要一个正则表达式并与异常信息相匹配。如果是一个自定义的异常,你也可以使用 exc_info 或任何其他的变量名来询问异常的额外参数。exc_info对象将是ExceptionInfo类型的。关于ExceptionInfo的完整参考,请参见pytest文档。
网友评论