第 7 章 · 07 fixture 进阶:作用域与 conftest
本章目标:掌握
scope四种作用域的实例化时机与共享语义,理解 conftest.py 的层级覆盖机制与autouse自动注入,并学会在"共享省钱"和"状态隔离"之间做正确取舍。
7.1 scope:fixture 活多久
默认情况下,每个测试都会拿到新创建的 fixture 实例(scope="function")。当构造昂贵时(如真实 SMTP 连接、数据库引擎、Docker 容器),可以提升作用域让实例被复用:
# content of conftest.py
import smtplib
import pytest
@pytest.fixture(scope="module")
def smtp_connection():
# 整个测试模块只创建一次,模块内所有测试共享同一连接
return smtplib.SMTP("smtp.gmail.com", 587, timeout=5)# content of test_module.py —— 两个测试收到的是同一个对象
def test_ehlo(smtp_connection):
response, msg = smtp_connection.ehlo()
assert response == 250
def test_noop(smtp_connection):
resp = smtp_connection.noop()
assert resp[0] == 250官方定义的五种取值及销毁时机:
| scope | 实例化时机 | teardown 执行时机 |
|---|---|---|
function(默认) | 每个测试前 | 该测试结束后 |
class | 每个测试类第一个测试前 | 类内最后一个测试结束后 |
module | 每个模块第一个测试前 | 模块最后一个测试结束后 |
package | 包内第一个测试前 | 包内最后一个测试结束后 |
session | 整场会话第一次被请求时 | 全部测试结束后 |
高作用域 + 可变状态 = 灾难
把 scope 提升到 module/session 后,所有测试共享同一份可变状态。一个测试往共享列表里 append、改了共享字典,后面的测试就会莫名失败。原则:高作用域 fixture 应该提供"只读或不可变"的资源(连接、配置对象),需要修改状态的资源保持 function 级。
7.2 conftest.py 层级:就近覆盖
conftest.py 是分层生效的:目录树上的每个 conftest 只对其所在目录及子目录可见。当两层 conftest 定义了同名 fixture 时,更靠近测试的那层获胜——这实现了"默认值 + 局部定制":
tests/
├── conftest.py # def base_url(): return "https://api.prod.example.com"
└── local/
├── conftest.py # def base_url(): return "http://localhost:8000"
└── test_local.py # 用到 base_url 时得到 localhost 版本# tests/conftest.py
import pytest
@pytest.fixture
def base_url() -> str:
"""全项目默认指向预发环境"""
return "https://staging.example.com"# tests/local/conftest.py
import pytest
@pytest.fixture
def base_url() -> str:
"""local 目录下的测试全部改打本地服务——直接同名覆盖"""
return "http://localhost:8000"这个机制同样适用于对第三方插件的 fixture 做定制,是 pytest 实现"约定优于配置"的关键设计。
7.3 autouse:不需要请求的 fixture
有些横切关注点(临时目录切换、屏蔽网络、注入公共补丁)每个测试都需要,逐个写参数太啰嗦。autouse=True 让 fixture 被所有测试自动请求:
# tests/conftest.py
import os
import pytest
@pytest.fixture(autouse=True)
def _isolated_cwd(tmp_path):
"""让所有测试自动运行在临时目录里,避免污染仓库"""
old = os.getcwd()
os.chdir(tmp_path) # setup:进入临时目录
yield tmp_path
os.chdir(old) # teardown:回到原目录
@pytest.fixture(autouse=True)
def _fail_on_slow(monkeypatch):
# autouse 也可以只做副作用(不返回值)
monkeypatch.setenv("APP_ENV", "testing")# 任何测试文件——无需声明任何参数就享受上述环境
def test_writes_config():
with open("config.yaml", "w") as f:
f.write("debug: true")
assert os.path.exists("config.yaml") # 写进的是 tmp_path,不会弄脏仓库autouse 同样受 scope 控制;它也可以正常返回值,只是"不请求也能用"。克制使用:autouse 本质是隐式全局行为,数量失控后没人能说清某个测试的完整执行环境。
7.4 作用域冲突:高不能依赖低
一条硬规则:高层作用域的 fixture 不能依赖低层作用域的 fixture。session 级 fixture 若依赖 function 级 fixture,pytest 会直接报 ScopeMismatch 错误——因为 session 实例只建一次,而它依赖的东西却要每测试重建一次,逻辑上无法成立。
常见踩坑场景与解法:
@pytest.fixture(scope="session")
def engine():
return create_engine("sqlite:///:memory:")
@pytest.fixture(scope="session")
def tables(engine): # session 依赖 session ✔
...如果确实需要在 session 级初始化中用到 per-test 数据,正确方向是反转结构:把"贵而不变"的部分做成高作用域,per-test 差异放在低作用域 fixture 中去引用它。
7.5 取舍决策表
| 需求 | 选择 |
|---|---|
| 构造快、含可变状态(列表/模型对象) | function(默认) |
| 昂贵连接、进程池、容器,且只读使用 | module / session |
| 某目录下所有测试都要换环境/换配置 | 该目录 conftest.py 里覆盖同名 fixture |
| 全局横切行为(切目录、环境变量、禁网) | conftest + autouse=True |
7.6 本章小结
scope五档:function/class/module/package/session,决定实例创建与 teardown 时机;- 高作用域省时间但共享状态,只读资源才适合提升作用域;
- conftest 分层 + 同名就近覆盖 = 项目级默认与局部定制的标准模式;
autouse=True提供无需声明的横切 setup/teardown,要克制;- session/module fixture 不能依赖更低作用域的 fixture(
ScopeMismatch)。
🧪 随堂测验
点击你认为正确的选项。答错时会展示正确答案与原因解析。
1. scope="module" 的 fixture 在什么时刻执行 teardown?
2. session 作用域的 fixture 依赖了 function 作用域的 fixture,会发生什么?
3. tests/api/conftest.py 与 tests/conftest.py 定义了同名 fixture api_client,tests/api/ 下的测试会拿到哪个?
4. 以下哪种用法最适合 autouse?
🛠️ 动手实践
- 把第 6 章
db_connfixture 分别改成 module 和 session 作用域,插入数据后写第二个测试验证"能看到第一个测试的数据",体会共享状态的双刃剑,最后恢复 function 并解释为什么。 - 在项目根 conftest.py 定义
app_mode = "prod",再在某子目录 conftest.py 覆盖为"test",写一个打印该值的测试确认就近覆盖生效。 - 编写一个 autouse fixture:记录每个测试的开始/结束时间戳并在 teardown 打印耗时,跑完整个测试套件观察输出。
至此 fixture 的核心心智模型已完整,下一章进入工厂模式、finalizer 与 request 对象等高级技巧。