Pytest测试框架入门指南
Python测试入门可能令人生畏。虽然标准库提供了一些测试工具,但存在使用不便的局限。
作为Python最流行的测试框架之一,Pytest既能处理高度复杂的测试场景,又不会强制开发者使用其所有功能。你完全可以编写简单的测试,同时享受快速完善的测试运行器和报告功能。
Pytest的核心优势在于降低测试编写门槛。你可以创建零依赖、零配置的测试函数并立即执行。
本文涵盖Pytest入门所需的基础知识,助你提升测试套件质量。
学习目标
完成本文后,你将能够使用Pytest编写测试、理解其强大的错误报告机制,并利用其完整的测试运行器功能。你将自如运用测试函数和类,并能合理选择适用场景。
你将具备以下能力:
- 使用Pytest编写新测试或扩展现有测试套件
- 通过Pytest的错误报告精确定位故障
- 将Pytest作为库和命令行工具使用
核心目标
掌握Pytest测试编写方法,编写更多更优质的测试,成为更高效的开发者。
01 Pytest基础知识
Pytest高度可配置,能处理复杂测试套件。但入门测试不需要复杂配置——测试框架越简单越好。
本节结束后,你将掌握编写首个测试并使用Pytest运行所需的所有知识。
命名规范
Python没有强制规定测试文件和目录的布局规则。但了解以下规范可实现自动化测试发现和执行,无需额外配置。
测试目录与测试文件
主测试目录通常命名为tests。该目录可置于项目根目录,也可与代码模块放在一起。
注意:本文默认采用项目根目录下的tests目录结构。
查看名为jformat的小型Python项目结构:
.
├── README.md
├── jformat
│ ├── __init__.py
│ └── main.py
├── setup.py
└── tests
└── test_main.py
tests目录位于项目根目录,包含一个测试文件test_main.py。此示例展示两个关键规范:
- 使用
tests目录存放测试文件和嵌套测试目录 - 测试文件以
test前缀命名,标识其包含测试代码
建议:避免使用单数形式test作为目录名,因为它与Python内置模块冲突。始终使用复数形式tests。
测试函数
Pytest的一大优势是支持普通函数作为测试。测试函数必须带有test_前缀,以确保Pytest能收集并执行它们。
简单的测试函数示例:
def test_main():
assert "a string value" == "a string value"
提示:如果你熟悉unittest,在测试函数中使用assert可能会令你惊喜。通过普通assert语句,Pytest能提供详细的失败报告。
测试类与测试方法
测试类和测试方法遵循以下命名规则:
- 测试类以
Test开头 - 测试方法以
test_开头
与Python的unittest不同,Pytest无需继承任何基类。
以下示例展示检查用户名的测试类:
class TestUser:
def test_username(self):
assert default() == "default username"
运行测试
Pytest既是测试框架也是测试运行器。作为命令行工具,它主要负责:
- 自动发现并收集所有测试文件、测试类和测试函数
- 执行全部测试并跟踪结果
- 提供详尽的测试报告
假设我们有test_main.py文件:
# test_main.py 内容
def test_main():
assert True
在文件所在目录运行pytest命令:
$ pytest
=========================== test session starts ============================
platform -- Python 3.10.1, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /private/tmp/project
collected 1 item
test_main.py . [100%]
============================ 1 passed in 0.00s =============================
Pytest无需任何配置即可自动收集并执行测试。
强大的断言机制
示例测试均使用普通的assert语句。在普通Python中,assert的失败报告不够详细。但Pytest在后台对其进行了增强,无需额外代码或配置即可提供丰富的比较信息。
使用普通assert意味着你可以使用所有Python运算符:>、<、!=、>=、<=等。这是Pytest最核心的特性之一——无需学习新的断言语法。
比较长字符串时的失败报告:
================================ FAILURES =================================
____________________________ test_long_strings _____________________________
def test_long_strings():
left = "this is a very long strings to be compared with another long string"
right = "This is a very long string to be compared with another long string"
> assert left == right
E AssertionError: assert 'this is a ve...r long string' == 'This is a ve...r long string'
E - This is a very long string to be compared with another long string
E ? ^
E + this is a very long strings to be compared with another long string
E ? ^ +
test_main.py:4: AssertionError
Pytest精准指出字符串开头大小写不一致以及多余字符的问题。
列表比较失败报告:
________________________________ test_lists ________________________________
def test_lists():
left = ["sugar", "wheat", "coffee", "salt", "water", "milk"]
right = ["sugar", "coffee", "wheat", "salt", "water", "milk"]
> assert left == right
E AssertionError: assert ['sugar', 'wh...ater', 'milk'] == ['sugar', 'co...ater', 'milk']
E At index 1 diff: 'wheat' != 'coffee'
E Full diff:
E - ['sugar', 'coffee', 'wheat', 'salt', 'water', 'milk']
E ? ---------
E + ['sugar', 'wheat', 'coffee', 'salt', 'water', 'milk']
E ? +++++++++
test_main.py:9: AssertionError
报告不仅指出索引1的值不同,还提供完整的差异对比。
字典比较失败时:
____________________________ test_dictionaries _____________________________
def test_dictionaries():
left = {"street": "Ferry Ln.", "number": 39, "state": "Nevada", "zipcode": 30877, "county": "Frett"}
right = {"street": "Ferry Lane", "number": 38, "state": "Nevada", "zipcode": 30877, "county": "Frett"}
> assert left == right
E AssertionError: assert {'county': 'F...rry Ln.', ...} == {'county': 'F...ry Lane', ...}
E Omitting 3 identical items, use -vv to show
E Differing items:
E {'street': 'Ferry Ln.'} != {'street': 'Ferry Lane'}
E {'number': 39} != {'number': 38}
E Full diff:
E {
E 'county': 'Frett',...
E
E ...Full output truncated (12 lines hidden), use '-vv' to show
使用-vv标志可以查看更详细的差异:
$ pytest -vv
____________________________ test_dictionaries _____________________________
def test_dictionaries():
left = {"street": "Ferry Ln.", "number": 39, "state": "Nevada", "zipcode": 30877, "county": "Frett"}
right = {"street": "Ferry Lane", "number": 38, "state": "Nevada", "zipcode": 30877, "county": "Frett"}
> assert left == right
E AssertionError: assert {'county': 'Frett',\n 'number': 39,\n 'state': 'Nevada',\n 'street': 'Ferry Ln.',\n 'zipcode': 30877} == {'county': 'Frett',\n 'number': 38,\n 'state': 'Nevada',\n 'street': 'Ferry Lane',\n 'zipcode': 30877}
E Common items:
E {'county': 'Frett', 'state': 'Nevada', 'zipcode': 30877}
E Differing items:
E {'number': 39} != {'number': 38}
E {'street': 'Ferry Ln.'} != {'street': 'Ferry Lane'}
E Full diff:
E {
E 'county': 'Frett',
E - 'number': 38,
E ? ^
E + 'number': 39,
E ? ^
E 'state': 'Nevada',
E - 'street': 'Ferry Lane',
E ? - ^
E + 'street': 'Ferry Ln.',
E ? ^
E 'zipcode': 30877,
E }
Pytest不仅找出所有差异,还提供精确的错误定位,方便快速修复。
02 测试类与方法
除了测试函数,Pytest也支持测试类。无需继承,只需遵循简单规则。使用测试类能提升代码灵活性和可复用性。
创建测试类
假设我们有一个检查文件内容是否包含"yes"的函数:
import os
def is_done(path):
if not os.path.exists(path):
return False
with open(path) as _f:
contents = _f.read()
if "yes" in contents.lower():
return True
elif "no" in contents.lower():
return False
在test_files.py中创建测试类:
class TestIsDone:
def test_yes(self):
with open("/tmp/test_file", "w") as _f:
_f.write("yes")
assert is_done("/tmp/test_file") is True
def test_no(self):
with open("/tmp/test_file", "w") as _f:
_f.write("no")
assert is_done("/tmp/test_file") is False
注意:生产环境中建议使用tempfile库安全地创建临时文件。
运行测试:
$ pytest -v test_files.py
============================= test session starts ==============================
Python 3.9.6, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /private/
collected 2 items
test_files.py::TestIsDone::test_yes PASSED [ 50%]
test_files.py::TestIsDone::test_no PASSED [100%]
============================== 2 passed in 0.00s ===============================
辅助方法
测试类提供多种设置和清理方法:
setup():每个测试前执行teardown():每个测试后执行setup_class():类中所有测试前执行一次teardown_class():类中所有测试后执行一次
使用teardown()清理临时文件:
class TestIsDone:
def teardown(self):
if os.path.exists("/tmp/test_file"):
os.remove("/tmp/test_file")
def test_yes(self):
with open("/tmp/test_file", "w") as _f:
_f.write("yes")
assert is_done("/tmp/test_file") is True
def test_no(self):
with open("/tmp/test_file", "w") as _f:
_f.write("no")
assert is_done("/tmp/test_file") is False
使用setup()统一文件路径:
class TestIsDone:
def setup(self):
self.tmp_file = "/tmp/test_file"
def teardown(self):
if os.path.exists(self.tmp_file):
os.remove(self.tmp_file)
def test_yes(self):
with open(self.tmp_file, "w") as _f:
_f.write("yes")
assert is_done(self.tmp_file) is True
def test_no(self):
with open(self.tmp_file, "w") as _f:
_f.write("no")
assert is_done(self.tmp_file) is False
自定义辅助方法:
def write_tmp_file(self, content):
with open(self.tmp_file, "w") as _f:
_f.write(content)
整合后的完整测试类:
class TestIsDone:
def setup(self):
self.tmp_file = "/tmp/test_file"
def teardown(self):
if os.path.exists(self.tmp_file):
os.remove(self.tmp_file)
def write_tmp_file(self, content):
with open(self.tmp_file, "w") as _f:
_f.write(content)
def test_yes(self):
self.write_tmp_file("yes")
assert is_done(self.tmp_file) is True
def test_no(self):
self.write_tmp_file("no")
assert is_done(self.tmp_file) is False
何时使用类而非函数
没有严格规定,但可以参考以下问题:
- 测试是否需要共享设置或清理逻辑?
- 将相关测试分组是否逻辑更清晰?
- 测试套件是否包含一定数量的测试?
- 测试是否能从公共辅助方法中受益?
03 实践练习
练习目标:使用Pytest测试并修复一个包含bug的函数,体验Pytest强大的错误报告功能。
待测试的函数admin_command()能根据参数决定是否使用sudo前缀系统命令:
def admin_command(command, sudo=True):
"""
Prefix a command with `sudo` unless explicitly not needed.
Expects `command` to be a list.
"""
if sudo:
["sudo"] + command
return command
步骤1 - 创建测试文件
创建test_exercise.py并添加测试:
class TestAdminCommand:
def command(self):
return ["ps", "aux"]
def test_no_sudo(self):
result = admin_command(self.command(), sudo=False)
assert result == self.command()
def test_sudo(self):
result = admin_command(self.command(), sudo=True)
expected = ["sudo"] + self.command()
assert result == expected
步骤2 - 运行测试并定位问题
$ pytest test_exercise.py
=================================== FAILURES ===================================
__________________________ TestAdminCommand.test_sudo __________________________
def test_sudo(self):
result = admin_command(self.command(), sudo=True)
expected = ["sudo"] + self.command()
> assert result == expected
E AssertionError: assert ['ps', 'aux'] == ['sudo', 'ps', 'aux']
E At index 0 diff: 'ps' != 'sudo'
E Right contains one more item: 'aux'
test_exercise.py:24: AssertionError
=========================== short test summary info ============================
FAILED test_exercise.py::TestAdminCommand::test_sudo
========================= 1 failed, 1 passed in 0.04s ==========================
结果显示test_sudo失败——result中没有"sudo"。原因是admin_command中列表拼接的结果未被返回。
步骤3 - 修复bug
修改admin_command函数:
def admin_command(command, sudo=True):
if sudo:
return ["sudo"] + command
return command
重新运行测试:
$ pytest -v test_exercise.py
============================= test session starts ==============================
Python 3.9.6, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: /private
collected 2 items
test_exercise.py::TestAdminCommand::test_no_sudo PASSED [ 50%]
test_exercise.py::TestAdminCommand::test_sudo PASSED [100%]
============================== 2 passed in 0.00s ===============================
步骤4 - 扩展功能并添加测试
首先添加检查非列表输入的测试:
import pytest
def test_non_list_commands(self):
with pytest.raises(TypeError):
admin_command("some command", sudo=True)
更新函数,添加类型检查和错误信息:
def admin_command(command, sudo=True):
if not isinstance(command, list):
raise TypeError(f"was expecting command to be a list, but got a {type(command)}")
if sudo:
return ["sudo"] + command
return command
更新测试以验证错误消息:
def test_non_list_commands(self):
with pytest.raises(TypeError) as error:
admin_command("some command", sudo=True)
assert error.value.args[0] == "was expecting command to be a list, but got a <class 'str'>"
最终运行全部测试:
$ pytest -v test_exercise.py
============================= test session starts ==============================
collected 3 items
test_exercise.py::TestAdminCommand::test_no_sudo PASSED [ 33%]
test_exercise.py::TestAdminCommand::test_sudo PASSED [ 66%]
test_exercise.py::TestAdminCommand::test_non_list_commands PASSED [100%]
============================== 3 passed in 0.00s ===============================
最终验证
完整test_exercise.py包含:
admin_command()函数,包含参数验证和错误处理TestAdminCommand测试类,包含三个测试方法- 全部测试通过,无错误
04 知识检测
- Pytest中使用普通assert语句的优势是?
A. 加快测试执行速度
B. 支持使用所有Python运算符进行比较
C. 支持并行执行测试 - 什么时候应该将测试分组到类中?
A. 测试需要共享设置或清理逻辑
B. 测试需要使用assert语句
C. 测试需要随机化以提高覆盖率 - Pytest能否作为Python库使用?
A. 不能,Pytest没有库模块
B. 不能,只能通过插件扩展
C. 可以,Pytest提供了可导入的模块和辅助程序
总结
本文介绍了Pytest的基础用法——Python最受欢迎的测试框架之一。我们从编写测试的基础规范开始,演示了命令行工具的使用方法,然后探讨了测试类和方法带来的灵活性提升。
通过实际练习,你体验了如何为有问题的函数编写测试,利用测试定位bug并进行修复。
现在,你应该能够轻松阅读和使用Pytest编写的测试,无论是使用函数还是类的形式。你可以为项目编写新测试或扩展现有测试套件。
