Skip to content

第 8 章 · fixture 高级模式

本章目标:掌握"工厂 fixture"、request 对象、fixture 自身参数化与 usefixtures 标记,能够为任意复杂度的资源编写可复用的测试基础设施。

8.1 工厂 fixture:一次注入,多次生成

第 7 章里每个 fixture 只产生一个对象。但真实场景常常是:一个测试里需要创建多个同类资源——比如给购物车加三件商品、给群组拉三个用户。直接把 fixture 写死成单个返回值就不够用了。

官方推荐的解法是 factory as fixture(工厂即夹具)模式:fixture 不返回数据,而是返回一个生成数据的函数

python
import pytest


@pytest.fixture
def make_customer_record():
    # 返回的不是记录本身,而是"造记录"的工厂函数
    def _make_customer_record(name):
        return {"name": name, "orders": []}

    return _make_customer_record


def test_customer_records(make_customer_record):
    c1 = make_customer_record("Lisa")
    c2 = make_customer_record("Mike")
    c3 = make_customer_record("Meredith")

    assert {c["name"] for c in (c1, c2, c3)} == {"Lisa", "Mike", "Meredith"}

工厂函数可以带任意参数,还能配合 yield批量清理——这是该模式最实用的形态:

python
@pytest.fixture
def make_customer_record():
    created_records = []

    def _make(name):
        record = {"name": name, "orders": []}
        created_records.append(record)  # 记录所有创建过的对象
        return record

    yield _make  # 测试期间持续提供工厂

    for record in created_records:  # teardown 阶段统一销毁
        record.clear()


def test_orders(make_customer_record):
    rec = make_customer_record("Alice")
    rec["orders"].append({"sku": "A1", "qty": 2})
    assert len(rec["orders"]) == 1
    # 测试结束后所有由工厂创建的记录都会被清理

记忆口诀

"要一个用 fixture,要多个用 factory"。工厂模式把"造多少个"的决定权交还给测试函数,同时清理逻辑仍集中在 fixture 里。

8.2 request 对象:让 fixture 感知请求上下文

fixture 函数可以声明一个特殊参数 request,它是 pytest 注入的请求上下文对象。常用属性:

属性含义
request.paramfixture 参数化时当前参数值(见 8.3)
request.module发起请求的测试模块
request.cls / request.instance请求所在的测试类/实例
request.function请求的测试函数
request.addfinalizer(fn)注册 teardown 回调
request.getfixturevalue("name")按名字动态获取其他 fixture

一个经典用法是从测试模块读取配置,让同一 fixture 服务不同配置的测试:

python
# content of conftest.py
import smtplib
import pytest


@pytest.fixture(scope="module")
def smtp_connection(request):
    # 从使用方模块读取 smtpserver 属性,没有就用默认值
    server = getattr(request.module, "smtpserver", "smtp.gmail.com")
    conn = smtplib.SMTP(server, 587, timeout=5)
    yield conn
    print(f"finalizing {conn} ({server})")
    conn.close()
python
# content of test_anothersmtp.py
smtpserver = "mail.python.org"  # 会被 conftest 里的 fixture 读取


def test_showhelo(smtp_connection):
    code, _ = smtp_connection.noop()
    assert code == 250

getfixturevalue 则支持运行期动态决定依赖哪个 fixture(普通参数写法要求依赖在收集期就确定):

python
@pytest.fixture
def driver(request):
    browser = getattr(request.module, "BROWSER", "chromium")
    # 动态获取同名的 fixture(需事先定义 chromium/firefox fixture)
    impl = request.getfixturevalue(browser)
    yield impl
    impl.quit()

8.3 fixture 自身参数化:一套测试 × N 种实现

第 5 章我们参数化了测试函数;同样地,fixture 也可以参数化——通过 @pytest.fixture(params=[...]) 声明,每个参数值都会让 fixture 执行一次,所有依赖它的测试自动重跑一遍。参数值通过 request.param 获取:

python
# content of conftest.py
import pytest


@pytest.fixture(params=["sqlite", "postgres"])
def db(request):
    # 每种数据库各执行一次,request.param 是当前参数值
    if request.param == "sqlite":
        engine = FakeSQLiteEngine()
    else:
        engine = FakePostgresEngine()
    yield engine
    engine.drop_all()


class FakeSQLiteEngine:
    def query(self, sql):
        return [{"id": 1}]
    def drop_all(self): ...


class FakePostgresEngine(FakeSQLiteEngine):
    pass
python
# content of test_repo.py
def test_find_user(db):
    rows = db.query("SELECT * FROM users")
    assert rows[0]["id"] == 1

运行结果会生成两个测试项:test_find_user[sqlite]test_find_user[postgres]测试代码一行未改,就完成了"业务逻辑在两种存储上行为一致"的验证——这正是 fixture 参数化相对测试函数参数化的独特价值:它天然面向"多实现同一契约"的场景。

测试 ID 默认取参数值的字符串表示,可以用 ids 定制,也可以用 pytest.param 给个别参数打 mark:

python
@pytest.fixture(
    params=[
        "v1",                       # 正常跑
        pytest.param("legacy", marks=pytest.mark.xfail(reason="旧格式不再支持")),
    ],
    ids=["current", "old"],
)
def api_version(request):
    return request.param

8.4 usefixtures:不打扰函数签名的依赖声明

有时测试只是需要某个副作用(比如切到空目录),并不想接收 fixture 的返回值。这时可以在函数签名里塞一个不用的参数,也可以更优雅地用 usefixtures 标记:

python
# content of conftest.py
import os
import tempfile
import pytest


@pytest.fixture
def cleandir():
    with tempfile.TemporaryDirectory() as newpath:
        old_cwd = os.getcwd()
        os.chdir(newpath)
        yield
        os.chdir(old_cwd)
python
# content of test_setenv.py
import os
import pytest


@pytest.mark.usefixtures("cleandir")
class TestDirectoryInit:
    def test_cwd_starts_empty(self):
        assert os.listdir(os.getcwd()) == []
        with open("myfile", "w", encoding="utf-8") as f:
            f.write("hello")

    def test_cwd_again_starts_empty(self):
        assert os.listdir(os.getcwd()) == []

usefixtures 可以叠加多个 fixture(@pytest.mark.usefixtures("a", "b")),也可以在模块级用 pytestmark = pytest.mark.usefixtures("cleandir") 对整个文件生效,甚至写进配置文件的 usefixtures 选项对整个项目生效。

usefixtures 的两个限制

  1. 不能用在 fixture 函数上——fixture 之间的依赖只能通过参数签名声明;
  2. 因为拿不到返回值,需要 fixture 数据时必须走参数注入。

8.5 本章小结

  • 工厂 fixture 返回"生成函数",适合一测多用 + 统一清理的场景;
  • request 对象提供 param/module/function/addfinalizer/getfixturevalue 等上下文能力;
  • @pytest.fixture(params=...) 让依赖它的全部测试按参数组合自动重跑,是验证"多实现同一契约"的利器;
  • usefixtures 适用于只要副作用不要返回值的场景,但不能用于 fixture 内部。

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. "工厂 as fixture"模式下,fixture 应该返回什么?

2. 在 @pytest.fixture(params=["a", "b"]) 的 fixture 里,如何拿到当前参数值?

3. 关于 @pytest.mark.usefixtures,下列说法错误的是?

4. 想在 fixture 运行期根据条件动态加载另一个 fixture,应该用?

🛠️ 动手实践

  1. 为一个假的 UserRepository 编写工厂 fixture make_users:能按需创建 N 个用户并在 teardown 时统一清空仓库。
  2. 用 fixture 参数化让同一个 test_stack_behavior 同时跑在 listcollections.deque 两种实现上。
  3. 把本章 cleandir fixture 改造成"临时目录 + 还原工作目录",并分别用参数注入和 usefixtures 两种方式各写一个测试,体会两者的差异。

下一章我们把 pytest 内置的"瑞士军刀"——tmp_pathcapsyscaplogmonkeypatchcache 一网打尽:第 9 章