Python 基础体系 · 第 67/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python pytest 完整基础:Fixture、参数化、标记、插件和隔离
pytest 是一个用于自动化测试的 Python 测试框架。它支持普通函数测试、类测试、unittest.TestCase 测试,并通过断言重写、Fixture、参数化和插件机制覆盖从单元测试到功能测试的场景。pytest 默认发现名称匹配 test_*.py 或 *_test.py 的测试文件,以及其中名称以 test 开头的函数、方法和符合规则的测试类。(docs.pytest.org)
本文以 Python 3.14 为代码环境,重点解释以下问题:
- pytest 如何发现和执行测试;
- 普通
assert为什么能产生详细失败信息; - Fixture 如何声明依赖、管理生命周期和清理资源;
- 参数化如何把一个测试展开成多个独立测试;
- 标记如何表达测试元数据、选择测试、跳过测试和记录预期失败;
- 插件如何扩展 pytest 的命令、Fixture、收集和报告能力;
- 如何利用临时目录、环境恢复、资源作用域和替身边界实现测试隔离;
- pytest 与
unittest、Mock、异步测试和 Hypothesis 的关系。
一、先建立测试运行模型
1. 一个测试框架到底做什么
一个测试系统通常包含四个角色:
- 测试对象:被测试的函数、类、模块或服务;
- 测试用例:给定输入并验证行为;
- 测试运行器:发现、调度并执行测试;
- 测试报告器:汇总通过、失败、跳过和错误。
unittest 文档将这些概念分别称为 test fixture、test case、test suite 和 test runner。pytest 使用函数参数、收集节点和插件系统实现了相同的整体流程,但不要求测试函数继承某个基类。(docs.python.org)
一个最小 pytest 测试如下:
# src/calculator.py
def add(a: int, b: int) -> int:
return a + b
# tests/test_calculator.py
from src.calculator import add
def test_add_two_numbers():
assert add(2, 3) == 5
运行:
python3.14 -m pytest -q
预期输出类似:
. [100%]
1 passed
这里的执行过程是:
- pytest 从当前目录或配置的
testpaths开始收集; - 找到
tests/test_calculator.py; - 找到
test_add_two_numbers; - 调用测试函数;
- 执行
assert add(2, 3) == 5; - 表达式结果为
True,测试通过。
python -m pytest 与直接调用 pytest 大体等价,但前者会按照 Python 的常规行为把当前目录加入 sys.path,因此在解释器、虚拟环境和导入路径存在差异时更容易确认实际使用的 Python 环境。(docs.pytest.org)
2. pytest 为什么直接使用 assert
在 unittest 中,通常写成:
self.assertEqual(add(2, 3), 5)
而 pytest 使用:
assert add(2, 3) == 5
pytest 会在导入测试模块时对测试模块中的 assert 进行重写,从而在失败时记录表达式中的中间值。例如:
def test_add():
actual = add(2, 3)
expected = 6
assert actual == expected
失败报告通常能够显示:
E assert 5 == 6
E + where 5 = add(2, 3)
其原因不是 Python 的普通 assert 自己知道如何比较两个值,而是 pytest 的导入钩子改写了断言代码,使其在失败路径中保留比较双方及相关表达式信息。直接导入的辅助模块中的断言不一定自动获得同样的重写能力;插件或辅助库如果希望启用断言重写,需要显式注册。(docs.pytest.org)
这也说明一个边界:测试断言应该直接写在 pytest 收集到的测试模块或经过注册的测试支持模块中。不要把关键断言隐藏在一个普通工具函数里,然后期待失败信息始终包含完整的中间值。
二、测试发现、节点 ID 和命令行选择
1. 默认发现规则
默认情况下,pytest 会递归查找:
test_*.py
*_test.py
然后收集:
def test_function():
...
class TestSomething:
def test_method(self):
...
测试类通常以 Test 开头,并且不应定义需要参数的 __init__。测试函数和方法通常以 test 开头。(docs.pytest.org)
下面的文件不会按默认规则被收集:
# checks.py
def check_add():
assert 1 + 1 == 2
可以通过配置修改发现规则:
# pytest.toml
[pytest]
python_files = ["test_*.py", "*_check.py"]
python_functions = ["test_*", "check_*"]
python_classes = ["Test*", "Check*"]
但这些命名配置不会改变 unittest.TestCase 内部方法的发现方式,因为这类测试由 pytest 委托给 unittest 的测试发现逻辑。(docs.pytest.org)
2. 节点 ID
pytest 为每个测试建立一个节点 ID。常见形式如下:
tests/test_calculator.py::test_add_two_numbers
tests/test_user.py::TestUser::test_create
tests/test_math.py::test_add[small]
因此可以精确执行某个测试:
python3.14 -m pytest \
tests/test_calculator.py::test_add_two_numbers
执行某个类:
python3.14 -m pytest tests/test_user.py::TestUser
执行某个参数实例:
python3.14 -m pytest 'tests/test_math.py::test_add[small]'
在 shell 中,包含 [ 和 ] 的节点 ID 最好使用引号包围,避免被 shell 当作特殊模式处理。pytest 官方文档将文件、类、函数和参数实例都视为可组合的 collection argument。(docs.pytest.org)
3. 使用 -k 和 -m 选择测试
-k 根据测试节点名称进行筛选:
python3.14 -m pytest -k "user and not slow"
它匹配文件名、类名和函数名等节点名称。
-m 根据标记筛选:
python3.14 -m pytest -m integration
python3.14 -m pytest -m "not slow"
二者解决的问题不同:
-k关心名称;-m关心测试元数据。
如果测试名称不稳定,使用自定义标记通常比依赖名称更清晰。(docs.pytest.org)
三、Fixture:测试资源的声明式依赖
1. Fixture 的定义
Fixture 是测试执行前所需的准备,以及测试完成后的清理。它可以创建:
- 测试数据;
- 临时目录;
- 数据库连接;
- HTTP 客户端;
- 配置对象;
- Mock 或环境变量;
- 后台服务进程。
pytest 中,测试函数通过参数名请求 Fixture:
import pytest
@pytest.fixture
def numbers():
return [1, 2, 3]
def test_numbers(numbers):
assert numbers == [1, 2, 3]
pytest 看到 test_numbers(numbers) 后,会按参数名查找名为 numbers 的 Fixture,执行它,并把返回值传给测试函数。(docs.pytest.org)
可以把上面的过程近似理解为:
resource = numbers()
test_numbers(numbers=resource)
但真实 pytest 还会负责缓存、作用域、依赖排序、异常处理和清理。
2. Fixture 可以依赖其他 Fixture
Fixture 的参数也表示依赖:
import pytest
@pytest.fixture
def first_item():
return "a"
@pytest.fixture
def items(first_item):
return [first_item]
def test_append(items):
items.append("b")
assert items == ["a", "b"]
依赖关系是:
first_item ──> items ──> test_append
pytest 会先执行 first_item,再执行 items,最后执行测试。
如果把 Fixture 看成有向图,节点是 Fixture 或测试,边表示“依赖”,那么 pytest 需要得到一个满足依赖关系的执行顺序。对上面的图来说,唯一合理的顺序是:
first_item
items
test_append
多个相互独立的 Fixture 没有必要人为依赖:
@pytest.fixture
def user():
return {"name": "alice"}
@pytest.fixture
def token():
return "token-value"
def test_request(user, token):
assert user["name"] == "alice"
assert token == "token-value"
这里 user 和 token 之间没有依赖关系。测试声明二者即可,pytest 会在满足依赖的前提下安排执行。
3. 同一测试中的 Fixture 缓存
在一次测试执行中,同一个 Fixture 默认只创建一次:
import pytest
@pytest.fixture
def state():
return []
@pytest.fixture
def prepared_state(state):
state.append("prepared")
return state
def test_state(prepared_state, state):
assert prepared_state is state
assert state == ["prepared"]
执行过程:
prepared_state请求state;- pytest 创建
state,得到列表[]; prepared_state向该列表添加"prepared";- 测试函数再次请求
state; - pytest 返回已经缓存的同一个列表对象。
因此这里的 state 不是第二个新列表。Fixture 缓存的不只是返回值,也包括创建过程中对对象产生的副作用。(docs.pytest.org)
这会带来一个常见误解:
@pytest.fixture
def shared_list():
return []
这并不意味着所有测试共享同一个列表。默认 function 作用域下,每个测试函数会获得一次新的 Fixture 实例。
真正造成跨测试共享的通常是:
- 使用了
scope="module"、scope="session"; - Fixture 返回了进程级全局对象;
- Fixture 内部引用了模块级可变对象;
- 外部数据库、缓存或文件没有清理。
4. Fixture 作用域
Fixture 的作用域控制实例的创建和销毁频率:
| 作用域 | 创建与销毁周期 |
|---|---|
function |
每个测试函数一次 |
class |
每个测试类一次 |
module |
每个测试模块一次 |
package |
每个测试包一次 |
session |
整个测试会话一次 |
默认作用域是 function。更宽的作用域可以减少昂贵资源的创建次数,但会增加共享状态和清理难度。pytest 官方文档明确列出了这五种作用域,并说明 Fixture 会在第一次被请求时创建,在对应作用域结束时销毁。(docs.pytest.org)
示例:
import pytest
@pytest.fixture(scope="module")
def connection():
print("create connection")
connection = {"connected": True}
yield connection
print("close connection")
def test_first(connection):
assert connection["connected"] is True
def test_second(connection):
assert connection["connected"] is True
同一个模块中的两个测试通常会得到同一个 connection 对象:
create connection
test_first
test_second
close connection
但是这段代码的安全性取决于测试是否修改了 connection。如果第一个测试执行:
connection["connected"] = False
第二个测试就可能受到影响。
因此作用域选择可以形式化为一个取舍:
总成本 ≈ 创建成本 × 创建次数 + 状态污染成本 × 共享程度
function作用域:创建成本高,但状态污染范围小;session作用域:创建成本低,但状态污染范围大;module和class:在二者之间折中。
这个公式不是 pytest 的规范公式,而是分析工程取舍的模型。创建成本和共享风险都应通过实际测试时间、资源约束和故障记录验证,而不是凭感觉把所有 Fixture 改成 session。
5. yield Fixture 和清理顺序
对于需要清理的资源,推荐使用 yield:
import pytest
@pytest.fixture
def temporary_resource():
resource = {"open": True}
yield resource
resource["open"] = False
def test_resource(temporary_resource):
assert temporary_resource["open"] is True
yield 之前是准备阶段,yield 返回的对象会传给测试,yield 之后是清理阶段。
若有多个 Fixture:
import pytest
@pytest.fixture
def database():
print("setup database")
yield "database"
print("teardown database")
@pytest.fixture
def transaction(database):
print("setup transaction")
yield "transaction"
print("teardown transaction")
def test_query(transaction):
assert transaction == "transaction"
执行顺序是:
setup database
setup transaction
test_query
teardown transaction
teardown database
原因是资源依赖关系:
database ──> transaction ──> test_query
transaction 依赖 database,所以必须先创建 database;清理时则反向执行,先关闭事务,再关闭数据库。pytest 对 yield Fixture 采用反向拆卸顺序。(docs.pytest.org)
6. Fixture 准备失败时会发生什么
考虑:
import pytest
@pytest.fixture
def resource_a():
print("setup a")
yield "a"
print("teardown a")
@pytest.fixture
def resource_b():
print("setup b")
raise RuntimeError("cannot create b")
yield "b"
print("teardown b")
def test_failure(resource_a, resource_b):
assert False
执行路径是:
setup a
setup b
resource_b 抛出异常
teardown a
test_failure 不执行
resource_b 在 yield 前失败,因此它位于 yield 后的清理代码不会执行;但已经成功创建的 resource_a 仍然会清理。(docs.pytest.org)
因此,资源创建和资源注册应尽可能紧邻:
@pytest.fixture
def user(client):
user = client.create_user()
yield user
client.delete_user(user)
不要先注册一个清理动作,再做一长串可能失败的操作,否则清理代码可能执行在资源并未完整创建的状态上。
7. request.addfinalizer 的适用边界
除了 yield,还可以使用 request.addfinalizer:
import pytest
@pytest.fixture
def user(client, request):
user = client.create_user()
def cleanup():
client.delete_user(user)
request.addfinalizer(cleanup)
return user
注意:一旦调用 addfinalizer,pytest 就会在之后执行这个清理函数,即使 Fixture 随后又抛出异常。因此应当在资源成功创建后再注册清理逻辑。pytest 文档也明确指出,finalizer 是后进先出执行的,并且过早注册可能导致不安全清理。(docs.pytest.org)
通常:
- 线性资源生命周期:使用
yield; - 需要在多个条件分支中注册不同清理动作:使用
addfinalizer; - 资源尚未创建成功前:不要注册针对该资源的清理动作。
8. autouse Fixture
autouse=True 的 Fixture 不需要在测试参数中显式声明:
import pytest
@pytest.fixture(autouse=True)
def reset_global_state():
GLOBAL_STATE.clear()
yield
GLOBAL_STATE.clear()
它会自动应用到作用域内符合条件的测试。
autouse 适合真正对所有测试都成立的基础约束,例如:
- 每个测试前清空全局注册表;
- 禁止测试访问真实网络;
- 统一设置时区;
- 每个测试后恢复某个全局状态。
但它也会隐藏测试依赖:
@pytest.fixture(autouse=True)
def create_default_user():
...
此时测试函数的签名无法说明它依赖默认用户,阅读测试的人需要跳转到 conftest.py 才能理解前置条件。
因此 autouse 的核心边界是:它应表达“作用域内所有测试都必须拥有的环境”,不应承载某个少数测试才需要的业务数据。
9. conftest.py 与 Fixture 可见性
公共 Fixture 通常放在 conftest.py:
project/
├── src/
│ └── calculator.py
├── tests/
│ ├── conftest.py
│ ├── test_calculator.py
│ └── api/
│ ├── conftest.py
│ └── test_api.py
└── pytest.toml
# tests/conftest.py
import pytest
@pytest.fixture
def valid_user():
return {"id": 1, "name": "alice"}
# tests/test_user.py
def test_user_name(valid_user):
assert valid_user["name"] == "alice"
pytest 会根据测试所在目录查找可见的 conftest.py。下层目录中的 Fixture 通常只对该目录及其子目录可见。conftest.py 的作用是提供测试配置和 Fixture,不应被应用代码导入。
四、参数化:把一个测试展开为多个独立实例
1. 参数化的定义
参数化是指:使用同一套测试逻辑,针对多组输入和预期结果分别执行。
import pytest
@pytest.mark.parametrize(
"text, expected",
[
("3+5", 8),
("2+4", 6),
("6*9", 54),
],
)
def test_eval(text, expected):
assert eval(text) == expected
这不是一次测试里循环三次,而是 pytest 收集出三个独立测试实例:
test_eval[3+5-8]
test_eval[2+4-6]
test_eval[6*9-54]
如果第三组失败,pytest 可以定位到具体参数实例,而不是只告诉你“循环中的某一步失败”。pytest 官方文档将 @pytest.mark.parametrize 定义为对测试函数参数提供多个参数集合。(docs.pytest.org)
2. 为什么不直接写循环
下面的测试虽然能验证三组数据,但失败定位较弱:
def test_eval_all():
cases = [
("3+5", 8),
("2+4", 6),
("6*9", 54),
]
for text, expected in cases:
assert eval(text) == expected
如果失败,测试报告只会显示 test_eval_all。参数化版本则把测试空间显式展开:
测试函数 × 参数集合
设测试函数为 T,参数集合为:
P = {p1, p2, ..., pn}
参数化执行的结果是:
T(p1), T(p2), ..., T(pn)
每个 T(pi) 都有独立的节点 ID、结果和失败上下文。因此参数化不仅减少重复代码,也改善了故障定位。
3. 参数 ID
默认参数 ID 可能不够易读,可以显式指定:
import pytest
@pytest.mark.parametrize(
"value, expected",
[
pytest.param("", 0, id="empty"),
pytest.param("abc", 3, id="ascii"),
pytest.param("杭州", 2, id="unicode"),
],
)
def test_length(value, expected):
assert len(value) == expected
执行某个实例:
python3.14 -m pytest 'tests/test_text.py::test_length[unicode]'
参数 ID 应该描述测试条件,而不是重复实现细节。例如:
empty
ascii
unicode
比:
param3
case2
更适合失败诊断。
4. 组合参数化与笛卡尔积
多个 parametrize 装饰器会产生组合:
import pytest
@pytest.mark.parametrize("a", [1, 2])
@pytest.mark.parametrize("b", [10, 20, 30])
def test_add(a, b):
assert a + b > 0
参数数量为:
2 × 3 = 6
执行实例等价于:
(1, 10)
(1, 20)
(1, 30)
(2, 10)
(2, 20)
(2, 30)
这里的风险是组合爆炸。若有四个参数,取值数量分别为 5、4、3、2,测试数量就是:
5 × 4 × 3 × 2 = 120
组合参数化适用于每个维度确实相互影响的情况。如果大多数组合没有意义,应使用显式参数集合:
@pytest.mark.parametrize(
"database, cache",
[
("sqlite", "memory"),
("postgres", "redis"),
("sqlite", "redis"),
],
)
def test_backend(database, cache):
...
5. 参数化异常
使用 pytest.raises 验证异常:
import pytest
def parse_port(value: str) -> int:
port = int(value)
if not 1 <= port <= 65535:
raise ValueError("port out of range")
return port
@pytest.mark.parametrize(
"value",
["abc", "0", "65536"],
)
def test_parse_port_rejects_invalid(value):
with pytest.raises((ValueError,)):
parse_port(value)
如果要检查异常消息:
def test_parse_port_message():
with pytest.raises(ValueError, match="out of range"):
parse_port("65536")
不要只写:
with pytest.raises(Exception):
parse_port("65536")
因为这可能把编程错误、网络错误甚至断言错误也当成“预期行为”。异常类型越接近契约,测试越能区分正确失败和意外失败。
6. 参数化 Fixture
Fixture 本身也可以参数化:
import pytest
@pytest.fixture(params=["sqlite", "memory"])
def storage(request):
return request.param
def test_storage_name(storage):
assert storage in {"sqlite", "memory"}
这会让依赖 storage 的测试针对每个 Fixture 参数分别执行。
Fixture 参数化适合替换整个测试环境,例如:
@pytest.fixture(params=["sqlite", "postgres"])
def database(request):
db = create_database(request.param)
yield db
db.close()
而测试函数参数化更适合替换输入数据:
@pytest.mark.parametrize("name", ["alice", "bob"])
def test_create_user(database, name):
...
两者组合后,测试实例数量是二者的乘积:
数据库后端数量 × 输入数据数量
因此 Fixture 参数化非常强大,但也更容易导致测试数量和外部资源消耗快速增长。
7. indirect=True
有时参数不是直接传给测试函数,而是传给 Fixture:
import pytest
@pytest.fixture
def user(request):
return {
"name": request.param,
"active": True,
}
@pytest.mark.parametrize(
"user",
["alice", "bob"],
indirect=True,
)
def test_user(user):
assert user["active"] is True
assert user["name"] in {"alice", "bob"}
这里 "alice" 和 "bob" 不是直接作为 user 参数传给测试,而是成为 user Fixture 的 request.param。
数据流是:
参数值 "alice"
│
▼
user Fixture 的 request.param
│
▼
Fixture 构造 {"name": "alice", ...}
│
▼
test_user(user)
indirect 适合把简单配置值转换成复杂资源,例如把数据库名称转换成连接对象,把文件名转换成已加载的文档。
五、标记:给测试附加可查询的元数据
1. 标记是什么
标记是附加在测试、测试类或测试模块上的元数据。pytest 使用 pytest.mark 创建标记:
import pytest
@pytest.mark.slow
def test_large_dataset():
...
标记本身不会自动改变测试逻辑。它的作用是:
- 让命令行选择测试;
- 让插件识别测试类别;
- 表达跳过和预期失败;
- 为测试报告提供分类信息。
标记只能作用于测试,不能直接作用于 Fixture。(docs.pytest.org)
2. 内置标记
常用内置标记包括:
skip:无条件跳过;skipif:满足条件时跳过;xfail:预期测试失败;parametrize:参数化;usefixtures:在不把返回值传入测试函数的情况下使用 Fixture;filterwarnings:针对测试过滤警告。(docs.pytest.org)
示例:
import sys
import pytest
@pytest.mark.skip(reason="功能尚未支持")
def test_future_feature():
assert False
@pytest.mark.skipif(
sys.platform == "win32",
reason="当前实现依赖 Unix 文件权限",
)
def test_unix_permission():
...
@pytest.mark.xfail(
reason="已知缺陷:等待修复",
raises=ValueError,
)
def test_known_bug():
...
skip 表示当前环境或阶段不应该执行。xfail 表示测试应该执行,但当前已知可能失败。
二者不能混用:
- 如果功能根本不适用于当前平台,用
skipif; - 如果功能适用,但实现存在已确认缺陷,用
xfail; - 如果只是测试作者不想处理失败,不应使用
xfail掩盖问题。
3. xfail 的严格模式
默认情况下,如果一个标记为 xfail 的测试实际通过,pytest 可能将其报告为 XPASS,但不一定让整个会话失败。
可以开启严格模式:
# pytest.toml
[pytest]
xfail_strict = true
严格模式下:
- 预期失败:
XFAIL; - 实际通过:
XPASS,并使测试会话失败。
这适合把 xfail 当作临时缺陷记录,而不是永久屏蔽机制。
例如:
@pytest.mark.xfail(strict=True, reason="等待 issue #123 修复")
def test_bug():
assert buggy_function() == "correct"
如果代码修复后测试通过,XPASS 会提醒团队删除这个过期标记。
4. 自定义标记及注册
可以定义业务分类标记:
import pytest
@pytest.mark.unit
def test_parse_config():
...
@pytest.mark.integration
def test_database_connection():
...
@pytest.mark.slow
def test_large_import():
...
应在配置文件中注册:
# pytest.toml
[pytest]
markers = [
"unit: fast isolated unit tests",
"integration: tests involving multiple components",
"slow: tests that may take significant time",
]
注册后可以通过:
python3.14 -m pytest --markers
查看标记说明,并避免未注册标记产生警告。pytest 文档建议在配置文件或 pytest_configure 钩子中注册自定义标记。(docs.pytest.org)
选择测试:
python3.14 -m pytest -m "unit and not slow"
python3.14 -m pytest -m integration
标记的命名应描述测试属性,而不是描述某一次运行。例如:
integration
slow
requires_network
比:
run_on_ci
run_before_release
temporary
更稳定,因为前者描述测试是什么,后者描述测试何时被使用。
六、插件:扩展 pytest 的运行能力
1. 插件是什么
插件是通过 pytest 扩展机制接入测试运行过程的 Python 包或本地模块。插件可以提供:
- 新的 Fixture;
- 新的命令行选项;
- 新的标记;
- 测试收集器;
- 测试执行钩子;
- 报告格式;
- 并行执行能力;
- 覆盖率、超时或框架集成。
第三方插件通常通过 pip 安装,安装后 pytest 会自动发现并集成,不需要在每次命令中显式激活。(docs.pytest.org)
例如:
python3.14 -m pip install pytest-cov
安装后可以使用其命令行能力:
python3.14 -m pytest --cov=src
但插件不是“免费功能”。它会引入:
- 额外依赖;
- 版本兼容约束;
- 新的 Fixture 和标记;
- 可能改变收集或执行行为的钩子;
- CI 与本地环境之间的差异。
所以应把测试插件作为项目依赖锁定,而不是依赖开发者机器上的全局安装。
2. pytest、插件和 conftest.py 的关系
可以把 pytest 运行过程抽象成:
flowchart TD
A[命令行] --> B[配置加载]
B --> C[插件发现]
C --> D[测试收集]
D --> E[Fixture 解析]
E --> F[测试执行]
F --> G[报告生成]
C --> E
C --> F
C --> G
关键路径如下:
- 命令行参数进入 pytest;
- pytest 加载配置文件;
- 自动发现已安装插件;
- 收集测试模块和测试节点;
- 根据测试函数参数解析 Fixture;
- 执行测试和清理;
- 由核心或插件生成报告。
conftest.py 可以看作项目本地插件的一种常用载体。它不一定是可发布的插件包,但可以提供 Fixture 和钩子。
3. 使用插件前先检查实际能力
查看当前环境:
python3.14 -m pytest --version
python3.14 -m pytest --trace-config
python3.14 -m pytest --fixtures
python3.14 -m pytest --markers
python3.14 -m pytest -h
这些命令分别帮助诊断:
- 实际运行的 pytest 版本;
- 加载了哪些插件;
- 当前可用 Fixture;
- 已注册标记;
- 核心和插件增加了哪些命令行选项。
pytest 的 -h 输出会包含已安装插件注册的命令行和配置选项。(docs.pytest.org)
如果出现:
fixture 'client' not found
应检查:
- 提供
client的插件是否安装; conftest.py是否位于测试目录的可见范围;- Fixture 名称是否拼写正确;
- 是否在错误的 Python 环境中安装了插件;
- 是否被插件的条件配置禁用了。
4. 一个本地插件示例
可以在 conftest.py 中添加自定义命令行参数:
# tests/conftest.py
import pytest
def pytest_addoption(parser):
parser.addoption(
"--env",
action="store",
default="test",
choices=["test", "staging"],
help="运行测试的目标环境",
)
@pytest.fixture
def target_env(request):
return request.config.getoption("--env")
测试:
def test_environment(target_env):
assert target_env in {"test", "staging"}
运行:
python3.14 -m pytest -q --env=test
python3.14 -m pytest -q --env=staging
这里的数据流是:
--env=staging
│
▼
pytest_addoption 注册并解析
│
▼
request.config.getoption("--env")
│
▼
target_env Fixture
│
▼
test_environment(target_env)
choices 让非法输入在测试开始前就失败:
python3.14 -m pytest --env=production
预期会得到命令行参数错误,而不是让测试运行到中途才发现环境名称不支持。
七、隔离:让测试不依赖执行顺序
1. 测试隔离的定义
测试隔离是指:一个测试的结果不应依赖其他测试是否先执行、执行多少次、是否失败,或测试运行器采用何种顺序。
理想情况下,对任意两个测试 A 和 B:
run(A) 的结果不应改变 run(B) 的结果
更强地说,如果测试系统状态为 S,测试执行会产生状态变化:
S --A--> S_A
S --B--> S_B
隔离要求 B 从一个等价的初始状态开始,而不是从 S_A 继续:
S --B--> S_B
而不是:
S --A--> S_A --B--> S_AB
如果 B 只有在 A 先运行时才通过,通常说明存在共享状态、未清理资源、全局替换未恢复或数据库数据泄漏。
2. Fixture 作用域与隔离
最安全的默认选择通常是:
@pytest.fixture
def cart():
return []
而不是:
@pytest.fixture(scope="session")
def cart():
return []
前者每个测试获得一个新列表,后者所有测试共享同一个列表。
错误示例:
import pytest
@pytest.fixture(scope="module")
def mutable_config():
return {"debug": False}
def test_enable_debug(mutable_config):
mutable_config["debug"] = True
def test_default_config(mutable_config):
assert mutable_config["debug"] is False
第二个测试会因为第一个测试修改了共享对象而失败。修复方式之一是缩小作用域:
@pytest.fixture
def mutable_config():
return {"debug": False}
另一种方式是每个测试显式重置状态,但这通常不如创建新对象直接。
3. 临时目录:使用 tmp_path
测试文件系统逻辑时,不要写入项目目录:
from pathlib import Path
def test_write_config(tmp_path: Path):
config_file = tmp_path / "config.ini"
config_file.write_text("debug=true\n", encoding="utf-8")
assert config_file.read_text(encoding="utf-8") == "debug=true\n"
tmp_path 是 pytest 提供的 pathlib.Path 对象,并且默认对每个测试函数提供唯一临时目录。(docs.pytest.org)
完整的数据流是:
- pytest 为当前测试创建临时目录;
- Fixture 将目录路径传给测试;
- 测试在该目录创建文件;
- 测试完成后由 pytest 处理临时目录生命周期;
- 其他测试获得不同目录。
这比固定使用:
Path("/tmp/my-test")
更安全,因为固定路径可能造成:
- 并发测试互相覆盖;
- 上一次失败遗留数据;
- 权限差异;
- Windows 与 Unix 路径差异;
- 测试运行用户不同导致的失败。
4. 需要跨测试复用临时文件时
pytest 还提供 tmp_path_factory,适合生成更大作用域的临时目录:
import pytest
@pytest.fixture(scope="session")
def shared_data_dir(tmp_path_factory):
path = tmp_path_factory.mktemp("shared-data")
(path / "large.txt").write_text("large content", encoding="utf-8")
return path
def test_file_exists(shared_data_dir):
assert (shared_data_dir / "large.txt").exists()
这里共享的是只读数据。如果测试会修改文件,应在每个测试中复制到自己的 tmp_path:
import shutil
def test_mutable_copy(shared_data_dir, tmp_path):
source = shared_data_dir / "large.txt"
target = tmp_path / "large.txt"
shutil.copy(source, target)
target.write_text("changed", encoding="utf-8")
assert target.read_text(encoding="utf-8") == "changed"
assert source.read_text(encoding="utf-8") == "large content"
共享只读输入通常风险较小;共享可变输入则需要复制、事务回滚或显式重置。
5. 环境变量、当前目录和全局对象
monkeypatch Fixture 用于安全修改:
- 对象属性;
- 字典项;
- 环境变量;
sys.path;- 当前工作目录。
修改会在请求该 Fixture 的测试或 Fixture 完成后自动恢复。(docs.pytest.org)
示例:
# src/settings.py
import os
def is_debug() -> bool:
return os.getenv("APP_DEBUG", "0") == "1"
# tests/test_settings.py
from src.settings import is_debug
def test_debug_enabled(monkeypatch):
monkeypatch.setenv("APP_DEBUG", "1")
assert is_debug() is True
def test_debug_disabled(monkeypatch):
monkeypatch.delenv("APP_DEBUG", raising=False)
assert is_debug() is False
这里第二个测试不依赖第一个测试的执行结果,因为 monkeypatch 会撤销环境变量修改。
修改对象属性:
class Clock:
def now(self) -> str:
return "real-time"
def test_clock(monkeypatch):
clock = Clock()
monkeypatch.setattr(clock, "now", lambda: "fixed-time")
assert clock.now() == "fixed-time"
6. Patch 的位置:替换“使用位置”
替身边界是隔离中最容易出错的部分。
假设应用代码这样导入:
# src/service.py
from src.gateway import fetch_data
def load():
return fetch_data()
错误的测试替换位置:
monkeypatch.setattr("src.gateway.fetch_data", lambda: {"ok": True})
service.py 在导入时已经把 fetch_data 绑定到 src.service.fetch_data。因此测试应替换使用位置:
def test_load(monkeypatch):
monkeypatch.setattr(
"src.service.fetch_data",
lambda: {"ok": True},
)
from src.service import load
assert load() == {"ok": True}
如果模块已经在测试文件顶部导入:
from src.service import load
也仍然应替换:
monkeypatch.setattr("src.service.fetch_data", lambda: {"ok": True})
规则不是“替换定义函数所在的模块”,而是:
替换被测试代码实际查找名称的位置。
这与 unittest.mock.patch 的核心使用原则相同。pytest 的 monkeypatch 适合简单、自动恢复的环境和属性修改;复杂调用断言、调用次数、返回值序列和异步替身则可以使用 unittest.mock。两者都不能替代对真实边界的判断。
7. 数据库隔离
数据库测试通常有三种隔离策略。
策略一:每个测试使用独立数据库
@pytest.fixture
def database(tmp_path):
path = tmp_path / "db.sqlite3"
db = connect(path)
create_schema(db)
yield db
db.close()
优点是隔离最强,缺点是创建数据库和 Schema 的成本较高。
策略二:每个测试使用事务并回滚
@pytest.fixture
def transaction(database):
transaction = database.begin()
yield transaction
transaction.rollback()
优点是速度较快,缺点是并非所有数据库操作都能被事务回滚,例如:
- 独立连接执行的操作;
- 某些 DDL;
- 外部服务副作用;
- 提交后触发的异步任务。
策略三:共享数据库并清理表
@pytest.fixture
def clean_database(database):
truncate_all_tables(database)
yield database
truncate_all_tables(database)
这种方式实现简单,但清理不完整时容易产生顺序相关失败。
测试数据库的隔离不只是“每次删表”。还要考虑:
- 序列和自增 ID 是否重置;
- 后台任务是否仍在写入;
- 连接池是否复用旧事务;
- 缓存是否保存旧数据;
- 测试并发时是否共享数据库实例;
- 失败后能否可靠恢复。
八、Fixture、参数化和标记的组合
下面给出一个完整示例,展示三者如何一起工作。
# src/user_service.py
from dataclasses import dataclass
@dataclass(frozen=True)
class User:
name: str
active: bool
def create_user(name: str, active: bool = True) -> User:
if not name.strip():
raise ValueError("name cannot be empty")
return User(name=name, active=active)
# tests/conftest.py
import pytest
@pytest.fixture
def user_factory():
def create(name: str = "alice", active: bool = True):
from src.user_service import create_user
return create_user(name=name, active=active)
return create
# tests/test_user_service.py
import pytest
@pytest.mark.unit
@pytest.mark.parametrize(
"name, active",
[
pytest.param("alice", True, id="active-user"),
pytest.param("bob", False, id="inactive-user"),
],
)
def test_create_user(user_factory, name, active):
user = user_factory(name=name, active=active)
assert user.name == name
assert user.active is active
@pytest.mark.unit
@pytest.mark.parametrize(
"invalid_name",
["", " ", "\t"],
ids=["empty", "space", "tab"],
)
def test_create_user_rejects_blank_name(user_factory, invalid_name):
with pytest.raises(ValueError, match="cannot be empty"):
user_factory(name=invalid_name)
注册标记:
# pytest.toml
[pytest]
testpaths = ["tests"]
addopts = "-ra"
markers = [
"unit: isolated unit tests",
]
运行:
python3.14 -m pytest -q
python3.14 -m pytest -m unit
python3.14 -m pytest -k "blank_name"
这里的执行展开为:
test_create_user[active-user]
test_create_user[inactive-user]
test_create_user_rejects_blank_name[empty]
test_create_user_rejects_blank_name[space]
test_create_user_rejects_blank_name[tab]
Fixture 负责创建对象工厂,参数化负责测试多个输入,标记负责分类和筛选。三者职责不同,不应把所有逻辑都塞进某一个机制中。
九、Fixture 工厂:当测试需要多次创建对象
如果测试中需要创建多个相似对象,直接使用 Fixture 返回工厂函数:
import pytest
@pytest.fixture
def make_user():
created = []
def factory(name: str):
user = {"name": name}
created.append(user)
return user
yield factory
created.clear()
def test_two_users(make_user):
alice = make_user("alice")
bob = make_user("bob")
assert alice != bob
assert [alice["name"], bob["name"]] == ["alice", "bob"]
普通 Fixture 返回一个对象:
test ──> user
工厂 Fixture 返回一个创建函数:
test ──> make_user ──> user1
└──> user2
工厂模式适用于:
- 一个测试需要创建多个用户;
- 测试需要控制对象创建顺序;
- 每个对象需要独立配置;
- 创建过程包含统一的默认值。
如果工厂内部访问了数据库或网络,仍需在工厂返回的对象上建立清理策略。yield 只清理 Fixture 本身,不会自动知道工厂函数后来创建了哪些外部资源。
十、失败诊断:区分测试失败、Fixture 错误和环境错误
pytest 的结果摘要通常包括:
PASSED:测试断言通过;FAILED:测试执行了,但断言失败;ERROR:测试或 Fixture 在建立测试环境时发生未处理异常;SKIPPED:测试被跳过;XFAIL:预期失败;XPASS:预期失败的测试实际通过。
例如 Fixture 错误:
import pytest
@pytest.fixture
def broken_resource():
raise RuntimeError("resource unavailable")
def test_uses_resource(broken_resource):
assert True
这个测试函数的断言根本不会执行,因为 Fixture 在测试进入前就失败了。诊断时应首先检查 traceback 中 pytest 报告的 Fixture 初始化位置,而不是只看测试函数最后一行。
常用命令:
# 显示更完整的失败信息
python3.14 -m pytest -vv
# 显示局部变量
python3.14 -m pytest --showlocals
# 只运行失败测试
python3.14 -m pytest --lf
# 先收集,不执行
python3.14 -m pytest --collect-only
# 查看慢测试
python3.14 -m pytest --durations=10
# 允许 print 输出直接显示
python3.14 -m pytest -s
--collect-only 用于判断“测试是否被发现”;-s 用于判断“输出是否被捕获”;--lf 用于快速复现最近失败;--durations 用于定位执行成本较高的测试。pytest 的命令行支持按文件、目录、关键词、节点 ID 和标记筛选测试。(docs.pytest.org)
十一、配置文件:把运行约束写入项目
pytest 支持多种配置文件,包括 pytest.toml、pytest.ini 和 pyproject.toml。当前稳定文档中,pytest.toml 是较新的配置格式;pyproject.toml 可以使用 [tool.pytest] 或传统的 [tool.pytest.ini_options],具体取决于 pytest 版本。不要在没有确认项目 pytest 版本的情况下随意使用新配置语法。(docs.pytest.org)
一个保守的 pyproject.toml 示例:
[tool.pytest.ini_options]
minversion = "8.0"
addopts = "-ra"
testpaths = [
"tests",
]
markers = [
"unit: isolated unit tests",
"integration: tests using multiple components",
"slow: slow-running tests",
]
各配置项的作用:
minversion:pytest 版本低于指定版本时拒绝运行;addopts:为每次运行追加默认选项;testpaths:无命令行路径时的测试搜索目录;markers:注册自定义标记。
addopts 应保持克制。把 -x、--lf 等调试选项写进全局配置,可能导致 CI 只运行部分测试或忽略其他失败。
十二、pytest 与 unittest 的关系
pytest 不是 unittest 的替代运行器,而是可以运行 unittest 测试的另一种测试框架入口。pytest 可以直接收集许多 unittest.TestCase 测试。(docs.pytest.org)
unittest 示例:
import unittest
class TestCalculator(unittest.TestCase):
def test_add(self):
self.assertEqual(2 + 3, 5)
可以使用:
python3.14 -m unittest
也可以使用:
python3.14 -m pytest
二者的核心差异包括:
| 方面 | pytest | unittest |
|---|---|---|
| 测试组织 | 普通函数即可 | 通常继承 TestCase |
| 断言 | 普通 assert |
assertEqual 等方法 |
| 前置资源 | Fixture 参数 | setUp、tearDown |
| 参数化 | 原生支持 | 通常需要额外机制 |
| 插件模型 | pytest 插件生态 | unittest 扩展方式不同 |
| 迁移成本 | 适合新增函数式测试 | 适合保留既有类测试 |
如果项目已有大量 unittest 测试,不必一次性重写。可以先用 pytest 运行旧测试,再逐步将新的测试写成 pytest 风格。
unittest 的 setUp() 和 tearDown() 是每个测试方法的生命周期钩子;pytest Fixture 则可以独立组合、请求其他 Fixture,并通过作用域表达共享边界。(docs.python.org)
十三、异步测试的边界
async def 测试不是普通同步函数。pytest 核心需要由适配异步框架的插件负责事件循环、协程执行和异步 Fixture 管理;不能简单地把异步测试函数写成 async def 后期待 pytest 自动等待其结果。pytest 文档的弃用说明也强调,异步测试和异步 Fixture 的处理依赖专门的插件机制,尤其要避免同步测试错误请求异步 Fixture。(docs.pytest.org)
概念上:
async def test_async_operation():
result = await async_operation()
assert result == "ok"
这里必须明确:
- 使用哪个异步框架;
- 使用哪个 pytest 插件;
- 事件循环的作用域;
- 异步 Fixture 是否与同步测试混用;
- 后台任务是否在测试结束时取消;
- 连接和任务是否真正清理。
异步隔离尤其容易出现以下故障:
- 测试结束后仍有后台任务运行;
- 事件循环复用导致任务或上下文泄漏;
- 一个测试创建的异步客户端被下一个测试继续使用;
- 异步 Fixture 返回协程对象而不是已执行结果;
- 测试表面通过,但出现
unawaited coroutine警告。
因此异步测试的 Fixture 作用域不能只根据创建成本决定,还要根据事件循环、连接、任务和上下文变量的生命周期决定。
十四、Mock、Monkeypatch 与真实隔离边界
1. 什么适合替身
适合替换的边界通常是:
- 真实网络服务;
- 当前测试无法控制的时间;
- 随机数;
- 操作系统环境变量;
- 发送邮件、短信等外部副作用;
- 价格、汇率等外部数据;
- 成本高或不稳定的基础设施。
例如:
def get_discount(client, user_id):
response = client.get(f"/users/{user_id}/discount")
return response["percent"]
测试可以替换客户端:
def test_get_discount(monkeypatch):
class FakeClient:
def get(self, path):
assert path == "/users/1/discount"
return {"percent": 20}
from src.discount import get_discount
assert get_discount(FakeClient(), 1) == 20
2. 什么不应替换
如果把被测试函数本身也替换掉:
monkeypatch.setattr(
"src.discount.get_discount",
lambda client, user_id: 20,
)
那么测试验证的只是替身返回 20,并没有验证真实实现。
替换边界应位于被测试对象的外部依赖,而不是被测试对象本身。Mock 的调用断言也不能代替结果断言:
mock_client.get.assert_called_once_with(...)
这只能证明调用方式符合预期,不能证明业务结果正确。可靠测试通常同时检查:
- 外部依赖是否收到正确请求;
- 应用是否正确解释返回值;
- 异常和超时是否按契约传播或转换。
十五、属性测试和参数化的关系
普通参数化由工程师显式提供有限样例:
@pytest.mark.parametrize(
"value",
[0, 1, -1, 100, 1000],
)
def test_abs(value):
assert abs(value) >= 0
属性测试则描述对大量输入都成立的性质,例如:
对于任意整数 x,abs(x) >= 0
Hypothesis 等工具会根据生成策略生成输入,并在失败后尝试缩减为更小、更容易理解的反例。它还可以用于状态机测试,验证多个操作序列对系统状态的影响。
二者的关系可以概括为:
- 参数化:测试者指定案例;
- 属性测试:测试者指定输入空间和不变量;
- Fixture:建立测试环境;
- 标记:分类或控制运行;
- 插件:接入生成器、异步框架或报告系统。
例如,一个测试可以同时使用 Fixture 和属性测试:
def test_parser_never_returns_negative(parser, generated_value):
result = parser.parse(generated_value)
assert result.count >= 0
但属性测试不能修复错误的隔离边界。如果生成的每个输入都写入同一个外部文件或共享数据库,测试仍然可能相互污染。
十六、常见失败模式与诊断路径
1. Fixture 名称找不到
错误:
fixture 'client' not found
诊断顺序:
python3.14 -m pytest --fixtures
python3.14 -m pytest --trace-config
python3.14 -m pytest --collect-only
检查:
client是否在可见的conftest.py中;- 插件是否安装到了当前 Python 3.14 环境;
- Fixture 是否拼写错误;
- 测试目录是否被错误配置排除。
2. 参数化测试数量异常
如果预期 6 个测试,却收集到 18 个,通常存在多层组合:
Fixture 参数数量 × 测试参数数量 × 其他参数数量
使用:
python3.14 -m pytest --collect-only -q
查看实际生成的节点 ID。不要根据源码行数推测测试数量,应以收集结果为准。
3. 测试单独通过,整体失败
优先怀疑:
- 模块级或会话级可变 Fixture;
- 未恢复的环境变量;
- 当前工作目录没有恢复;
- 全局注册表未清空;
- 数据库事务未回滚;
- 后台线程或任务仍在运行;
- 缓存、单例或
sys.modules状态泄漏。
尝试反转顺序或随机顺序只能帮助暴露问题,不能修复隔离。真正的修复是找到共享状态的写入点和清理路径。
4. 标记没有生效
如果:
python3.14 -m pytest -m integration
没有选中预期测试,检查:
- 标记是否真正装饰了测试函数、类或模块;
- 是否把标记写在了 Fixture 上;
- 标记名是否拼写一致;
- 参数化时标记是否只应用于某个参数实例;
- 是否被其他命令行过滤条件排除。
参数实例可以单独添加标记:
@pytest.mark.parametrize(
"backend",
[
"sqlite",
pytest.param("postgres", marks=pytest.mark.integration),
],
)
def test_backend(backend):
...
这表示只有 postgres 参数实例带有 integration 标记。pytest 支持对参数集合中的单个实例附加标记。(docs.pytest.org)
十七、一套可运行的最小项目
目录:
pytest-demo/
├── pyproject.toml
├── src/
│ ├── __init__.py
│ └── calculator.py
└── tests/
├── conftest.py
└── test_calculator.py
应用代码:
# src/calculator.py
def divide(a: float, b: float) -> float:
if b == 0:
raise ZeroDivisionError("cannot divide by zero")
return a / b
Fixture:
# tests/conftest.py
import pytest
@pytest.fixture
def calculator():
from src import calculator
return calculator
测试:
# tests/test_calculator.py
import pytest
@pytest.mark.unit
@pytest.mark.parametrize(
"a, b, expected",
[
pytest.param(6, 3, 2.0, id="positive"),
pytest.param(-6, 3, -2.0, id="negative"),
pytest.param(0, 3, 0.0, id="zero"),
],
)
def test_divide(calculator, a, b, expected):
assert calculator.divide(a, b) == expected
@pytest.mark.unit
def test_divide_by_zero(calculator):
with pytest.raises(
ZeroDivisionError,
match="cannot divide by zero",
):
calculator.divide(1, 0)
配置:
# pyproject.toml
[tool.pytest.ini_options]
minversion = "8.0"
addopts = "-ra"
testpaths = ["tests"]
markers = [
"unit: isolated unit tests",
]
安装和运行:
python3.14 -m venv .venv
source .venv/bin/activate
python -m pip install -U pytest
python -m pytest -q
Windows PowerShell:
.venv\Scripts\Activate.ps1
python -m pip install -U pytest
python -m pytest -q
预期结果:
.... [100%]
4 passed
这个项目同时展示了:
- pytest 的默认发现;
assert断言;- Fixture 依赖;
- 参数化;
- 参数 ID;
- 异常断言;
- 自定义标记;
- 配置文件;
- Python 3.14 虚拟环境中的显式运行。
十八、如何选择这些机制
可以按测试中的问题选择工具:
| 问题 | 机制 |
|---|---|
| 测试需要共享准备逻辑 | Fixture |
| 测试需要创建多个相似资源 | Fixture 工厂 |
| 同一逻辑需要验证多组输入 | parametrize |
| 只运行某类测试 | 自定义标记与 -m |
| 某个平台不适用 | skipif |
| 已知缺陷等待修复 | xfail |
| 需要临时文件 | tmp_path |
| 需要修改环境变量或属性 | monkeypatch |
| 需要复杂调用断言 | unittest.mock |
| 需要覆盖异步框架 | 对应 pytest 插件 |
| 需要生成大量输入 | Hypothesis |
| 需要覆盖率或并行执行 | 对应插件 |
最重要的边界不是语法,而是生命周期:
测试输入在哪里产生?
测试资源何时创建?
资源被谁使用?
测试失败时如何清理?
下一个测试从什么状态开始?
如果这五个问题无法回答,Fixture、参数化和插件越多,测试系统反而越难诊断。
pytest 的基础能力可以归纳为一条执行链:
收集测试
→ 解析参数
→ 构建 Fixture 依赖图
→ 创建作用域内资源
→ 执行测试实例
→ 反向清理资源
→ 按标记、插件和配置生成报告
Fixture 管理准备和清理,参数化展开输入空间,标记表达测试元数据,插件扩展运行能力,隔离保证每个测试都从可解释的状态开始。理解这条链之后,pytest 的各种 API 不再是零散技巧,而是同一个测试执行模型中的不同控制点。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python 代码质量:Ruff、格式化、Lint、导入排序和规则治理
- 下一篇:Python unittest 与 Mock:替身边界、Patch、异步和脆弱测试
- 延伸:Python 属性测试:Hypothesis、生成策略、缩减和状态机测试
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论