pytest 全面用法详解
pytest 是一个功能强大、灵活且易于上手的 Python 测试框架,它支持简单的单元测试到复杂的功能测试,并提供了丰富的插件生态系统 。
一、 基础入门
1.1 安装与项目结构
bash
# 安装 pytest
pip install pytest# 安装常用插件
pip install pytest-html # 生成HTML报告
pip install pytest-xdist # 分布式/并行测试
pip install pytest-rerunfailures # 失败重试
pip install pytest-cov # 覆盖率报告
推荐的项目测试目录结构 :
project_root/
├── src/ # 源代码目录
│ └── your_module.py
├── tests/ # 测试目录
│ ├── conftest.py # 全局 fixture 配置
│ ├── test_module_a.py # 测试模块
│ ├── test_module_b.py
│ └── subpackage/ # 子包测试
│ └── test_sub.py
├── pytest.ini # pytest 配置文件
└── requirements.txt
1.2 测试发现与命名规则
pytest 会自动发现并运行测试,遵循以下命名规则 :
- 测试文件 :应以
test_开头或_test结尾(如test_example.py或example_test.py)。 - 测试类 :应以
Test开头,且不能有__init__方法。 - 测试函数/方法 :应以
test_开头。
示例测试文件:
python
# test_sample.py
def test_addition():
"""测试加法"""
assert 1 + 2 == 3
class TestMathOperations:
"""测试数学运算类"""
def test_multiplication(self):
"""测试乘法"""
assert 3 * 4 == 12
def test_division(self):
"""测试除法"""
assert 8 / 2 == 4
二、 核心运行方式与配置
2.1 运行测试命令
| 命令 | 说明 |
|---|---|
pytest |
运行当前目录及子目录下所有测试 |
pytest tests/ |
运行指定目录下的测试 |
pytest test_file.py |
运行指定文件内的测试 |
pytest test_file.py::test_func |
运行指定文件的特定测试函数 |
pytest test_file.py::TestClass::test_method |
运行指定类的特定测试方法 |
pytest -k "keyword" |
运行名称包含关键字的测试(模糊匹配) |
pytest -m marker_name |
运行带有特定标记的测试 |
pytest -v |
显示详细输出,包括每个测试用例的名称和结果 |
pytest -s |
允许测试中的 print 语句输出到控制台 |
pytest -x |
遇到第一个失败后立即停止测试 |
pytest --maxfail=2 |
允许的最大失败次数,达到后停止 |
pytest --lf 或 --last-failed |
只重新运行上次失败的测试 |
pytest --ff 或 --failed-first |
先运行失败的测试,再运行其他的 |
2.2 配置文件 pytest.ini
pytest.ini 文件用于配置 pytest 的默认行为,应放在项目根目录 。
ini
# pytest.ini 示例
[pytest]
# 指定测试文件搜索的路径
testpaths = tests
# 指定测试文件名的模式
python_files = test_*.py
# 指定测试类名的模式
python_classes = Test*
# 指定测试函数/方法名的模式
python_functions = test_*
# 自定义命令行选项的默认值
addopts = -v -s --strict-markers# 注册自定义标记,防止拼写错误
markers =
slow: marks tests as slow (deselect with '-m "not slow"')
integration: marks tests as integration tests smoke: marks tests as smoke tests
# 设置日志格式和级别
log_format = %(asctime)s [%(levelname)s] %(message)s
log_date_format = %Y-%m-%d %H:%M:%S
log_cli = true
log_cli_level = INFO
# 设置 junitxml 报告(用于 CI/CD)
junit_family = xunit2
2.3 Exit Code 含义
pytest 运行结束后会返回一个退出码,常用于 CI/CD 流程判断 :
| Exit Code | 含义 |
|---|---|
| 0 | 所有测试通过 |
| 1 | 测试运行失败(有测试用例失败) |
| 2 | 测试被用户中断(Ctrl+C) |
| 3 | 测试执行过程中发生内部错误 |
| 4 | pytest 命令行使用错误 |
| 5 | 未收集到任何测试用例 |
三、断言与测试报告
3.1 强大的断言机制
pytest 使用 Python 原生的 assert 语句进行断言,失败时会提供详细的上下文信息 。
python
def test_complex_assertions():
# 比较 assert "hello" == "hello"
assert 5 > 3 # 成员检查 assert "lo" in "hello"
assert 1 in [1, 2, 3]
# 类型检查
assert isinstance(42, int)
# 异常断言 import pytest with pytest.raises(ValueError, match="invalid literal"):
int("not_a_number")
# 近似相等(用于浮点数比较)
assert 0.1 + 0.2 == pytest.approx(0.3)
3.2 测试报告生成
bash
# 生成 JUnit XML 格式报告(常用于 Jenkins等 CI 工具)
pytest --junitxml=report.xml
# 生成 HTML 报告(需安装 pytest-html)
pytest --html=report.html --self-contained-html
# 生成 Allure 报告(需安装 allure-pytest)
pytest --alluredir=./allure-results
# 然后使用 allure serve ./allure-results 查看报告
# 生成覆盖率报告(需安装 pytest-cov)
pytest --cov=src --cov-report=html --cov-report=term-missing
四、 Fixture:测试夹具Fixture 是 pytest 最强大的特性之一,用于为测试提供固定的、可重用的上下文环境 。
4.1 基本用法
python
import pytest
# 定义一个简单的 fixture
@pytest.fixture
def sample_data():
"""提供测试数据"""
return {"name": "Alice", "age": 30}
# 在测试函数中使用 fixture
def test_user_age(sample_data):
assert sample_data["age"] == 30
# fixture 也可以返回更复杂的设置/清理逻辑
@pytest.fixture
def database_connection():
"""模拟数据库连接"""
print("
建立数据库连接...")
conn = {"connected": True, "db": "test_db"}
yield conn #测试执行时使用此处的值
print("关闭数据库连接...") # 清理代码在 yield 之后
conn["connected"] = False
def test_db_query(database_connection):
assert database_connection["connected"] is True
4.2 Fixture 作用域 (scope)
Fixture 可以有不同的作用域,控制其创建和销毁的频率 。
| 作用域 | 说明 | 适用场景 |
|---|---|---|
function |
默认值,每个测试函数运行一次 | 独立的测试数据 |
class |
每个测试类运行一次 | 类级别的共享设置 |
module |
每个模块运行一次 | 模块级别的共享资源 |
package |
每个包运行一次 | 包级别的配置 |
session |
一次测试会话运行一次 | 全局数据库连接、登录状态 |
python
import pytest
@pytest.fixture(scope="session")
def global_config():
"""会话级别的配置,整个测试过程只初始化一次"""
config = {"env": "test", "timeout": 30}
print("初始化全局配置")
yield config
print("清理全局配置")
@pytest.fixture(scope="module")
def shared_resource():
"""模块级别的共享资源"""
resource = {"data": [1, 2, 3]}
print("初始化模块资源")
yield resource
print("清理模块资源")
4.3 Fixture 参数化Fixture 本身也可以参数化,为不同的测试提供不同的数据 。
python
import pytest
@pytest.fixture(params=["chrome", "firefox", "edge"])
def browser(request):
"""参数化的浏览器 fixture"""
browser_name = request.param
print(f"
启动 {browser_name} 浏览器")
yield browser_name print(f"关闭 {browser_name} 浏览器")
def test_browser_compatibility(browser):
"""这个测试会使用三种浏览器各运行一次"""
assert browser in ["chrome", "firefox", "edge"]
# 这里可以编写针对不同浏览器的测试逻辑
4.4自动使用 Fixture (autouse)
设置 autouse=True 可以让 fixture 自动应用于所有测试,无需显式声明 。
python
import pytest
import time
@pytest.fixture(autouse=True)
def timer():
"""自动为每个测试计时"""
start = time.time()
yield
end = time.time()
print(f"
测试耗时: {end - start:.3f} 秒")
def test_fast():
time.sleep(0.1)
assert True
def test_slow():
time.sleep(0.5)
assert True
4.5 conftest.py:共享 Fixture
conftest.py 文件用于存放被多个测试文件共享的 fixture,pytest 会自动发现该文件 。
python
# tests/conftest.py
import pytest
import json
@pytest.fixture(scope="session")
def api_base_url():
"""共享的 API 基础 URL"""
return "https://api.example.com/v1"
@pytest.fixture
def read_test_data():
"""读取测试数据的共享 fixture"""
def _read_data(filename):
with open(f"tests/data/{filename}", "r") as f:
return json.load(f)
return _read_data
python
# tests/test_api.py
def test_user_endpoint(api_base_url, read_test_data):
"""使用 conftest.py 中定义的共享 fixture"""
base_url = api_base_url
user_data = read_test_data("user_data.json")
# 测试逻辑...
五、 参数化测试
参数化测试允许使用不同的输入数据多次运行同一测试逻辑 。
5.1 基本参数化
python
import pytest
# 使用 @pytest.mark.parametrize 装饰器
@pytest.mark.parametrize("input_val, expected", [
(1, 2), # 第一次运行: input_val=1, expected=2 (2, 4), # 第二次运行: input_val=2, expected=4 (3, 6), # 第三次运行: input_val=3, expected=6
])
def test_double(input_val, expected):
"""测试输入值翻倍"""
assert input_val * 2 == expected
# 参数化类中的测试方法
class TestMath:
@pytest.mark.parametrize("a,b,expected", [
(1, 2, 3),
(5, 5, 10),
(-1, 1, 0),
])
def test_addition(self, a, b, expected):
assert a + b == expected
5.2 参数化组合
python
import pytest
# 多个参数化装饰器会产生组合
@pytest.mark.parametrize("x", [0, 1])
@pytest.mark.parametrize("y", [2, 3])
def test_combinations(x, y):
"""这个测试会运行 2×2=4 次"""
print(f"测试组合: x={x}, y={y}")
assert isinstance(x + y, int)
5.3 为参数化用例添加 ID
python
import pytest
@pytest.mark.parametrize(
"input_val, expected",
[
pytest.param(1, 2, id="正整数"),
pytest.param(0, 0, id="零"),
pytest.param(-1, -2, id="负整数"),
pytest.param(2.5, 5.0, id="浮点数"),
]
)
def test_with_ids(input_val, expected):
"""每个参数化用例都有描述性 ID"""
assert input_val * 2 == expected
六、 标记 (Markers) 与跳过测试
6.1 内置标记
| 标记 | 说明 | 示例 |
|---|---|---|
@pytest.mark.skip |
无条件跳过测试 | @pytest.mark.skip |
@pytest.mark.skipif |
条件跳过测试 | @pytest.mark.skipif(sys.version_info < (3, 8), reason="需要 Python 3.8+") |
@pytest.mark.xfail |
预期测试会失败 | @pytest.mark.xfail |
@pytest.mark.parametrize |
参数化测试 | 见上一节 |
@pytest.mark.usefixtures |
强制使用 fixture | @pytest.mark.usefixtures("cleandir") |
6.2 自定义标记
python
import pytest
import time
# 使用在 pytest.ini 中注册的标记
@pytest.mark.slow
def test_slow_operation():
"""标记为慢测试,可以通过 -m "not slow" 跳过"""
time.sleep(2)
assert True
@pytest.mark.integration
def test_api_integration():
"""集成测试标记"""
# 调用外部 API 的测试
assert True
# 运行命令示例:
# pytestm "slow" # 只运行慢测试
# pytest -m "integration" # 只运行集成测试
# pytest -m "not slow" # 运行除慢测试外的所有测试
# pytest -m "slow and integration" # 运行同时满足两个标记的测试
6.3 跳过与预期失败
python
import pytest
import sys
# 无条件跳过
@pytest.mark.skip(reason="功能尚未实现")
def test_unimplemented():
assert False
# 条件跳过
@pytest.mark.skipif(
sys.platform != "linux",
reason="此测试仅在 Linux 系统上运行"
)
def test_linux_specific():
assert True
# 预期失败(测试通过反而会报错)
@pytest.mark.xfail(reason="已知问题,正在修复")
def test_buggy_feature():
assert 1 == 2 # 这个断言会失败,但符合预期
@pytest.mark.xfail
def test_should_fail_but_passes():
"""这个测试预期失败但实际通过了,会报告为 XPASS"""
assert True # 这不符合预期!
七、 钩子函数 (Hooks)
钩子函数允许你自定义和扩展 pytest 的行为,需要在 conftest.py 中实现 。
7.1 常用钩子函数| 钩子阶段 | 常用钩子函数 | 用途 |
| :--- | :--- | :--- |
| 初始化 | pytest_configure(config) | pytest 配置初始化时调用 |
| 测试收集 | pytest_collection_modifyitems(items) | 修改收集到的测试项(如重新排序) |
| | pytest_collection_finish(session) | 测试收集完成后调用 |
| 测试执行 | pytest_runtest_setup(item) | 单个测试执行前调用 |
| | pytest_runtest_call(item) | 执行测试函数时调用 |
| | pytest_runtest_teardown(item) | 单个测试执行后调用 |
| | pytest_runtest_makereport(item, call) | 为测试创建报告时调用 |
| 报告与日志 | pytest_report_teststatus(report) | 自定义测试状态显示 |
| | pytest_report_header(config) | 在报告头部添加自定义信息 |
| Fixture 生命周期 | pytest_fixture_setup(fixturedef) | fixture 设置时调用 |
| | pytest_fixture_post_finalizer(fixturedef) | fixture 清理后调用 |
7.2 钩子函数示例
python
# conftest.py
import pytest
from datetime import datetime
def pytest_configure(config):
"""pytest 配置初始化钩子"""
config.addinivalue_line(
"markers", "smoke: 冒烟测试"
)
print("pytest 配置初始化完成")
def pytest_report_header(config):
"""在报告头部添加自定义信息"""
return [
"项目: My Test Suite",
f"开始时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
"运行环境: Python " + sys.version.split()[0]
]
def pytest_collection_modifyitems(items):
"""修改收集到的测试项"""
# 将标记为 smoke 的测试移到最前面
smoke_items = []
other_items = []
for item in items:
if "smoke" in item.keywords:
smoke_items.append(item)
else:
other_items.append(item)
items[:] = smoke_items + other_items # 为所有测试项添加超时标记(示例)
for item in items:
item.add_marker(pytest.mark.timeout(30))
def pytest_runtest_makereport(item, call):
"""测试报告生成钩子"""
if call.when == "call": # 仅关注测试执行阶段
if call.excinfo is not None: # 测试失败 print(f"
测试失败: {item.name}")
print(f"错误类型: {call.excinfo.typename}")
print(f"错误信息: {call.excinfo.value}")
八、 常用插件与高级功能
8.1 并行测试 (pytest-xdist)
bash
# 使用所有 CPU 核心并行运行测试
pytest -n auto
# 指定并行进程数
pytest -n 4
# 并行运行并显示每个测试的输出
pytest -n 2 -v
# 按模块并行(避免测试间依赖问题)
pytest -n auto --dist=loadscope
8.2 失败重试 (pytest-rerunfailures)
bash
# 失败时重试 3 次pytest --reruns 3
# 失败时重试3 次,每次间隔 2 秒
pytest --reruns 3 --reruns-delay 2
# 仅对某些标记的测试重试
@pytest.mark.flaky(reruns=3, reruns_delay=1)
def test_flaky_api():
# 不稳定的 API 测试
pass
8.3 测试覆盖率 (pytest-cov)
bash
# 测量整个项目的覆盖率
pytest --cov=.
# 测量特定模块的覆盖率
pytest --cov=src.my_module
# 生成 HTML 报告并显示缺失的代码行
pytest --cov=src --cov-report=html --cov-report=term-missing
# 设置覆盖率阈值,低于阈值则测试失败
pytest --cov=src --cov-fail-under=80
8.4 测试排序 (pytest-order)
python
import pytest
@pytest.mark.order(1)
def test_first():
print("第一个运行")
assert True
@pytest.mark.order(3)
def test_third():
print("第三个运行")
assert True
@pytest.mark.order(2)
def test_second():
print("第二个运行")
assert True
# 运行命令: pytest --order-scope=function
九、 与 unittest 兼容
pytest 可以直接运行 unittest 风格的测试用例 。
python
import unittest
# unittest 风格的测试类
class TestUnittestStyle(unittest.TestCase):
def setUp(self):
"""每个测试方法前运行"""
self.data = [1, 2, 3]
def tearDown(self):
"""每个测试方法后运行"""
self.data = None def test_length(self):
self.assertEqual(len(self.data), 3)
def test_contains(self):
self.assertIn(2, self.data)
@unittest.skip("跳过示例")
def test_skip_example(self):
self.fail("这个测试不会运行")
# 可以直接用 pytest 命令运行
# pytest test_unittest_style.py
十、 最佳实践与常见问题
10.1 测试组织最佳实践
- 保持测试独立:每个测试应该独立运行,不依赖其他测试的状态或顺序。
- 使用有意义的名称:测试函数/方法名应该描述测试的目的。
- 一个断言一个概念:每个测试最好只验证一个逻辑概念。
- 合理使用 fixture:将通用的设置和清理逻辑提取到 fixture 中。
- 避免测试中的逻辑:测试代码应该简单直接,避免复杂的条件或循环。
- 测试正面和负面情况:既要测试正常路径,也要测试异常和边界情况。
10.2 常见问题解决
问题1:测试发现失败
bash
# 确保测试文件、类、函数命名符合规则
# 检查 pytest.ini 中的配置
# 使用 pytest --collect-only 查看哪些测试被收集到
问题2:Fixture 作用域问题
python
# 如果 fixture 状态在测试间意外共享,检查 scope 设置
# 考虑使用 function 作用域而不是 session/module 作用域
# 或者确保 fixture 返回的是不可变数据或深拷贝
问题3:测试依赖外部资源
python
# 使用 mocking 替代真实外部调用
from unittest.mock import Mock, patch
def test_external_api():
with patch('requests.get') as mock_get:
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {"key": "value"}
# 测试代码...
问题4:测试执行顺序依赖
bash
# 使用 pytest-randomly 插件随机化测试顺序,发现隐藏的依赖
pip install pytest-randomly
pytest --randomly-seed=42
10.3 性能优化建议
- 使用适当的 fixture 作用域:将昂贵的资源设置为 session 或 module 作用域。
- 并行执行 :对独立测试使用
pytest-xdist并行运行。 - 测试分组:将快速测试和慢速测试分开,使用标记区分。
- 避免不必要的导入:在 fixture 或测试函数内部进行耗时导入。
- 使用测试数据工厂:避免从文件重复读取测试数据。
python
# 使用 lazy fixture 延迟加载
@pytest.fixture(scope="session")
def expensive_resource():
"""昂贵的资源只在需要时初始化"""
resource = None def _get_resource():
nonlocal resource if resource is None:
print("初始化昂贵资源...")
resource = ExpensiveClass()
return resource return _get_resource
通过掌握以上内容,你可以充分利用 pytest 的强大功能来构建高效、可维护的测试套件。pytest 的插件生态系统非常丰富,可以根据项目需求选择合适的插件进一步扩展功能 。