本节目标:用 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 门禁 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。