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 与 unittestMock、异步测试和 Hypothesis 的关系。

一、先建立测试运行模型

1. 一个测试框架到底做什么

一个测试系统通常包含四个角色:

  1. 测试对象:被测试的函数、类、模块或服务;
  2. 测试用例:给定输入并验证行为;
  3. 测试运行器:发现、调度并执行测试;
  4. 测试报告器:汇总通过、失败、跳过和错误。

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

这里的执行过程是:

  1. pytest 从当前目录或配置的 testpaths 开始收集;
  2. 找到 tests/test_calculator.py
  3. 找到 test_add_two_numbers
  4. 调用测试函数;
  5. 执行 assert add(2, 3) == 5
  6. 表达式结果为 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"

这里 usertoken 之间没有依赖关系。测试声明二者即可,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"]

执行过程:

  1. prepared_state 请求 state
  2. pytest 创建 state,得到列表 []
  3. prepared_state 向该列表添加 "prepared"
  4. 测试函数再次请求 state
  5. 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 作用域:创建成本低,但状态污染范围大;
  • moduleclass:在二者之间折中。

这个公式不是 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_byield 前失败,因此它位于 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

关键路径如下:

  1. 命令行参数进入 pytest;
  2. pytest 加载配置文件;
  3. 自动发现已安装插件;
  4. 收集测试模块和测试节点;
  5. 根据测试函数参数解析 Fixture;
  6. 执行测试和清理;
  7. 由核心或插件生成报告。

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

应检查:

  1. 提供 client 的插件是否安装;
  2. conftest.py 是否位于测试目录的可见范围;
  3. Fixture 名称是否拼写正确;
  4. 是否在错误的 Python 环境中安装了插件;
  5. 是否被插件的条件配置禁用了。

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. 测试隔离的定义

测试隔离是指:一个测试的结果不应依赖其他测试是否先执行、执行多少次、是否失败,或测试运行器采用何种顺序。

理想情况下,对任意两个测试 AB

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)

完整的数据流是:

  1. pytest 为当前测试创建临时目录;
  2. Fixture 将目录路径传给测试;
  3. 测试在该目录创建文件;
  4. 测试完成后由 pytest 处理临时目录生命周期;
  5. 其他测试获得不同目录。

这比固定使用:

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.tomlpytest.inipyproject.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 参数 setUptearDown
参数化 原生支持 通常需要额外机制
插件模型 pytest 插件生态 unittest 扩展方式不同
迁移成本 适合新增函数式测试 适合保留既有类测试

如果项目已有大量 unittest 测试,不必一次性重写。可以先用 pytest 运行旧测试,再逐步将新的测试写成 pytest 风格。

unittestsetUp()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"

这里必须明确:

  1. 使用哪个异步框架;
  2. 使用哪个 pytest 插件;
  3. 事件循环的作用域;
  4. 异步 Fixture 是否与同步测试混用;
  5. 后台任务是否在测试结束时取消;
  6. 连接和任务是否真正清理。

异步隔离尤其容易出现以下故障:

  • 测试结束后仍有后台任务运行;
  • 事件循环复用导致任务或上下文泄漏;
  • 一个测试创建的异步客户端被下一个测试继续使用;
  • 异步 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 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。