《Python编程入门》14.2 fixture、参数化与 mock

把测试从单条函数升级为可复用、可组合的工程:讲清 fixture 的参数注入与四种作用域、yield 拆解、conftest.py 层级发现、tmp_path 与 monkeypatch,再用 parametrize 和 unittest.mock 演示 patch 该打在哪一层、autospec 与 call_args 怎么用。

本节目标:用 fixture 复用测试前置、用参数化把一组用例压成一条,并用 mock 隔离外部依赖,理解 patch「打在查找处」这条铁律。
适用版本:Python 3.12+(实测 3.14.6)

14.2 fixture、参数化与 mock

14.1 节的测试都是「自给自足」的函数:不依赖外部状态,也不用准备数据。但真实测试要连数据库、读临时文件、调外部 API、注入环境变量。本节讲三件让测试工程化的工具:fixture(前置/清理)、parametrize(批量用例)、mock(隔离外部依赖)。

14.2.1 fixture 是什么

fixture 是一段「测试运行前准备好、运行后收尾」的代码,通过 @pytest.fixture 声明:

import pytest

@pytest.fixture
def sample_user():
    user = {"id": 1, "name": "Alice"}
    return user

def test_user_name(sample_user):
    assert sample_user["name"] == "Alice"

关键机制:测试函数的参数名 = fixture 名。pytest 看到 test_user_name 需要参数 sample_user,就去找同名 fixture,调用它,把返回值注进去。这不是普通的函数调用,而是 pytest 的依赖注入。

14.2.2 作用域:一个 fixture 跑几次

fixture 用 scope 控制生命周期,取值有四个:

scope生命周期
function每个测试函数各跑一次(默认)
class每个测试类一次
module每个测试文件一次
session整个会话只跑一次

口说无凭,用打印证明。下面三个 fixture 各打印 setup/teardown,跑三个测试:

@pytest.fixture(scope="session")
def session_res():
    print("\n[setup] session fixture")
    yield "S"
    print("\n[teardown] session fixture")

@pytest.fixture(scope="module")
def module_res():
    print("[setup] module fixture")
    yield "M"
    print("[teardown] module fixture")

@pytest.fixture(scope="function")
def func_res():
    print("[setup] function fixture")
    yield "F"
    print("[teardown] function fixture")

实际输出(-s 关闭捕获,省略部分 run test_x 行):

[setup] session fixture
[setup] module fixture
[setup] function fixture
  >> run test_a
.[teardown] function fixture
[setup] function fixture
  >> run test_b
.[teardown] function fixture
[teardown] module fixture
[teardown] session fixture

结论一目了然:session 和 module 各只 setup 一次,function 每次测试都重新 setup/teardown。一个 session 级 fixture 里建立的数据库连接,会被所有测试共享;而 function 级的临时对象,测完就销毁。

带清理逻辑的 fixture 用 yield 分界:yield 之前是 setup,之后是 teardown——上面三个都是这种写法。yield 把值交给测试,测试结束后即使测试失败,控制权也会回到 yield 之后继续清理,这正是它比 return 强的地方。

14.2.3 conftest.py:fixture 的层级发现

fixture 不必写在使用它的文件里。放进 conftest.py,pytest 会沿目录向上逐级查找并合并:

project/
├── conftest.py              # root_token
└── tests/
    ├── conftest.py          # tests_token
    └── unit/
        └── test_hier.py

tests/unit/test_hier.py 里的测试既能用 root_token,也能用 tests_token——实测三个用例 test_sees_root、test_sees_tests、test_sees_both 全部 PASSED。规则是向上合并、就近覆盖:越靠近测试文件的 conftest.py 优先级越高,同名 fixture 会被子目录的版本覆盖。这是共享公共 fixture、又能局部定制的基础。

14.2.4 两个内置 fixture:tmp_path 与 monkeypatch

pytest 自带一批实用 fixture,最常用的两个是 tmp_path 和 monkeypatch:

import os
from pathlib import Path

def test_tmp_path(tmp_path):
    f = tmp_path / "data.txt"
    f.write_text("hello", encoding="utf-8")
    assert f.read_text(encoding="utf-8") == "hello"
    assert isinstance(tmp_path, Path)
    assert tmp_path.is_dir()

def test_monkeypatch_env(monkeypatch):
    monkeypatch.setenv("APP_MODE", "test")
    assert os.environ["APP_MODE"] == "test"

def test_env_restored():
    assert "APP_MODE" not in os.environ   # 上一个测试的改动已被回滚

tmp_path 给出一个每个测试独享的临时目录(pathlib.Path 对象),测试结束自动清理,不用自己 mkdtemp。monkeypatch 负责临时改环境变量、临时替换属性(monkeypatch.setattr(obj, "attr", value)),并在测试结束时自动还原——test_env_restored 能通过,就证明改动没有泄漏到别的测试。

14.2.5 parametrize:一组输入跑一条测试

同一个逻辑、多组输入,用 @pytest.mark.parametrize 一次性声明:

@pytest.mark.parametrize("a, b, expected", [
    (1, 1, 2),
    (2, 3, 5),
    (-1, 1, 0),
])
def test_add(a, b, expected):
    assert a + b == expected

pytest 会把它展开成三条独立的测试,任何一条失败都单独报告。给每个用例起可读名字用 ids=:

@pytest.mark.parametrize("text, expected", [
    ("hello", "HELLO"),
    ("", ""),
    ("123", "123"),
], ids=["lowercase", "empty", "digits"])
def test_upper(text, expected):
    assert text.upper() == expected

只想给某一个用例挂标记,用 pytest.param(..., marks=...):

@pytest.mark.parametrize("value", [
    2,
    pytest.param(3, marks=pytest.mark.skip(reason="3 是边界,先跳过")),
    pytest.param(0, marks=pytest.mark.xfail(reason="0 的已知问题")),
])
def test_positive(value):
    assert value > 0

实测收集结果(ids 和逐用例标记都生效了,节选):

test_param.py::test_add[1-1-2] PASSED                                    [ 11%]
test_param.py::test_upper[lowercase] PASSED                              [ 44%]
test_param.py::test_positive[3] SKIPPED (3 是边界,先跳过)               [ 88%]
7 passed, 1 skipped, 1 xfailed in 0.05s

14.2.6 参数化与 fixture 组合

两者可以叠加:fixture 负责准备,参数化负责铺输入,相乘出全部组合:

@pytest.fixture
def prefix():
    return "ID-"

@pytest.mark.parametrize("n", [1, 2, 3])
def test_with_fixture(prefix, n):
    assert f"{prefix}{n}" == f"ID-{n}"

fixture 自身也能参数化——在 @pytest.fixture(params=[...]) 里声明,配 request.param 取值,则每个参数都跑一遍依赖它的测试:

@pytest.fixture(params=["sqlite", "memory"])
def backend(request):
    return request.param

def test_backend(backend):
    assert backend in ("sqlite", "memory")

上例收集出 test_with_fixture[1/2/3] 三条与 test_backend[sqlite]、test_backend[memory] 两条,共 5 passed。

14.2.7 用 unittest.mock 隔离外部依赖

测试不该真的联网、真的发邮件。标准库的 unittest.mock 能造出「假对象」顶上。Mock 会记录它被怎么调用:

from unittest.mock import Mock

m = Mock()
m.return_value = 42
assert m() == 42            # 第 1 次调用
m("hello", key="value")     # 第 2 次调用
m("bye")                    # 第 3 次调用
assert m.call_count == 3
assert m.call_args == (("bye",), {})
assert m.call_args_list[1] == (("hello",), {"key": "value"})

MagicMock 是 Mock 的超集,额外支持魔术方法(__len__、__iter__、__getitem__ 等),需要「像真对象一样被 len()、被迭代」时用它:m = MagicMock() 后 len(m) == 0、m.__iter__.return_value = [1, 2, 3] 则 list(m) == [1, 2, 3]。不要装 pytest-mock——本节全部用标准库,够用。

14.2.8 patch 打在哪一层:一条铁律

这是 mock 最容易翻车的地方。规则是:

patch where it’s looked up, not where it’s defined(打在「查找处」,而不是「定义处」)。

假设有两个模块:

# gateway.py
def fetch_temp(city: str) -> int:
    raise RuntimeError("真实实现要联网,测试里绝不调用")
# weather.py
from gateway import fetch_temp

def describe(city: str) -> str:
    return f"{city}: {fetch_temp(city)}C"

weather 用 from gateway import fetch_temp,所以它命名空间里有一个自己的 fetch_temp 名字。要拦截 describe 里的调用,必须 patch weather.fetch_temp:

from unittest.mock import patch
import weather

@patch("weather.fetch_temp")
def test_describe_patched_where_looked_up(mock_fetch):
    mock_fetch.return_value = 25
    assert weather.describe("Beijing") == "Beijing: 25C"
    mock_fetch.assert_called_once_with("Beijing")

如果反过来 patch 定义处 gateway.fetch_temp,weather 里的名字不受影响,真实实现照样被调用——下面这个测试是故意通过的,它证明「打在定义处无效」:

@patch("gateway.fetch_temp")
def test_patch_where_defined_does_not_work(mock_fetch):
    mock_fetch.return_value = 25
    try:
        weather.describe("Beijing")
    except RuntimeError:
        pass   # 仍然抛 RuntimeError:patch 打错了层
    else:
        raise AssertionError("不该走到这里")

记住这条铁律,能省下大量「我明明 patch 了怎么还是走了真实代码」的排查时间。

patch 既能当装饰器,也能当上下文管理器:

def test_patch_context_manager():
    with patch("weather.fetch_temp", return_value=30) as m:
        assert weather.describe("Tokyo") == "Tokyo: 30C"
        assert m.call_count == 1

def test_with_monkeypatch(monkeypatch):
    monkeypatch.setattr("weather.fetch_temp", lambda city: 18)
    assert weather.describe("Paris") == "Paris: 18C"

后者用 monkeypatch 做同一件事,等价于 patch("weather.fetch_temp", ...)。简单替换用 monkeypatch 更顺手(自动还原),需要 call_args、side_effect 等断言能力时用 patch。

14.2.9 autospec 与 side_effect

默认的 Mock 对任何参数签名都照单全收,容易掩盖「参数传错」的 bug。autospec=True 会让 mock 照抄真实函数的签名,传错参数直接报 TypeError:

import pytest
from unittest.mock import patch
import mathlib

def test_autospec_blocks_wrong_signature():
    original = mathlib.real
    with patch("mathlib.real", autospec=True) as m:
        m.side_effect = original
        assert m(1, 2) == 3
        with pytest.raises(TypeError, match="too many positional arguments"):
            m(1, 2, 3)   # autospec 会校验签名

凡是 patch 有明确签名的函数,都建议加 autospec=True。side_effect 除了替换实现,还能让 mock 抛异常,用来测错误分支:

import pytest
from unittest.mock import Mock

def test_side_effect_exception():
    m = Mock()
    m.side_effect = TimeoutError("连接超时")
    with pytest.raises(TimeoutError, match="连接超时"):
        m()

配合 mock.call_args 断言「被用什么参数调过」,就能既验证「调对了」又验证「出错时怎么处理」。

小结

  • fixture 靠参数名注入,scope 决定跑几次:function 每次、session 全会话一次;yield 之前是 setup、之后是 teardown,测试失败也会执行。
  • conftest.py 向上逐级合并、就近覆盖,是共享 fixture 的标准做法。
  • tmp_path 给独占临时目录,monkeypatch 临时改环境变量/属性并自动还原。
  • parametrize 把多组输入展开成独立用例,支持 ids=、pytest.param(marks=...),并能与 fixture 叠加。
  • mock 的核心铁律是 patch 打在查找处;autospec=True 校验签名,side_effect 抛异常,call_args 断言调用。

测试写得再多,若没人拦着它退化,也守不住质量。下一节把覆盖率、ruff、mypy 和 CI 接起来,让「测试通过」成为一道自动化的门禁。

阅读导航:上一节:pytest 基础与断言 · 下一节:覆盖率、ruff/mypy 与 CI 门禁 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时