pytest Fixture 作用域如何影响测试隔离与速度
来源:17golang原创
时间:2026-10-08 15:38:52 341浏览 收藏
在测试数量还少的时候,Fixture 用默认配置通常没什么问题。等到用例增长、数据库或容器初始化越来越重,团队很容易走向两个极端:要么每个测试都重新创建昂贵资源,套件慢得难以接受;要么把所有 Fixture 都改成 session,速度是快了,却开始出现单测独立运行能过、整套运行偶发失败的问题。
先给结论:Fixture 的 scope 本质上定义了“一个实例被缓存并共享到多大范围”。作用域越小,隔离通常越强,初始化和清理次数也越多;作用域越大,重复初始化越少,但共享可变状态会放大测试耦合。正确做法不是一味扩大 scope,而是把“昂贵且稳定的基础设施”和“每个测试都会修改的业务状态”拆成两层。
pytest Fixture 官方文档:https://docs.pytest.org/en/stable/how-to/fixtures.html
先看结论:scope 决定缓存边界
pytest 提供五种常用作用域:function、class、module、package 和 session。默认值是 function。它们的区别不是 Fixture 能否被找到,而是同一个 Fixture 实例在多久之后被销毁和重新创建。
| 作用域 | 大致生命周期 | 隔离性 | 典型用途 |
|---|---|---|---|
function | 每个测试函数一次 | 最强 | 可变对象、事务、临时目录、Mock |
class | 每个测试类一次 | 较强 | 类内只读上下文、成组场景 |
module | 每个测试模块一次 | 中等 | 模块级静态样本、只读客户端 |
package | 每个测试包一次 | 较弱 | 包级服务或数据集 |
session | 整次 pytest 运行一次 | 最弱 | 容器、连接池、只读配置、昂贵服务 |
这里的“隔离性最弱”不等于 session scope 一定危险。真正危险的是把会被测试修改、又没有恢复机制的对象放进大作用域。一个只读配置对象放在 session 级通常很安全;一个所有测试共同增删记录的列表放在 session 级则很容易污染后续断言。

用户任务拆解:你真正想复用的是什么
我在调整大型测试套件时,会先把资源分成三类,而不是直接问“这个 Fixture 要不要改成 session”。
- 创建很便宜、状态会变化:优先使用 function。例如列表、请求上下文、临时文件、Mock 对象。
- 创建很昂贵、运行时基本只读:可以考虑 module、package 或 session。例如 Docker 服务、数据库引擎、模型权重、只读测试语料。
- 创建很昂贵、测试又必须修改:拆成两层。外层复用基础设施,内层为每个测试创建事务、命名空间或可回滚状态。
第三类最值得关注。很多“隔离和速度只能二选一”的困境,其实是因为 Fixture 把连接池、数据库连接、事务和业务数据揉在了一个对象里。拆开以后,连接池可以整场复用,而事务仍然每个测试独立。
组件实现一:可变状态默认使用 function
下面的购物车 Fixture 没写 scope,因此默认是 function。两个测试拿到的是两个不同的列表,前一个测试追加的数据不会泄漏给后一个测试。
import pytest
@pytest.fixture
def cart():
# 每个测试都获得独立列表,避免跨测试污染
return []
def test_add_product(cart):
# 本测试只修改自己的购物车
cart.append("keyboard")
assert cart == ["keyboard"]
def test_cart_starts_empty(cart):
# 即使前一个测试添加了商品,这里仍是空列表
assert cart == []
这种方式的价值不只是避免脏数据。它还让测试顺序不重要:单独运行、随机排序运行或并行运行时,行为更接近。只要对象创建成本很低,就没有必要为了少执行几次构造函数而牺牲这个特性。
组件实现二:session 引擎加 function 事务
数据库测试是最典型的双层结构。创建数据库引擎和连接池可能很慢,但“本测试写入的数据”必须在测试结束后撤销。可以让引擎使用 session scope,让事务和 ORM Session 保留默认 function scope。
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
@pytest.fixture(scope="session")
def db_engine():
# 昂贵的引擎与连接池在整次测试会话中只创建一次
engine = create_engine("postgresql+psycopg://tester:secret@127.0.0.1/app_test")
yield engine
# 所有测试结束后统一释放底层连接池
engine.dispose()
@pytest.fixture
def db_session(db_engine):
# 每个测试打开独立连接和事务,保留函数级隔离
connection = db_engine.connect()
transaction = connection.begin()
session = Session(bind=connection)
yield session
# 即使测试失败,yield 之后的清理仍会执行
session.close()
transaction.rollback()
connection.close()
这段结构里,速度来自 session 级的引擎复用,隔离来自 function 级事务的回滚。测试不应该主动提交到外层事务之外;如果业务代码必须测试真实提交,可以使用嵌套事务、独立 schema 或每测试唯一数据库名,原则仍然是“共享基础设施,不共享未清理的业务状态”。

可见性与依赖:conftest.py 怎么放
Fixture 的“定义位置”与“生命周期”是两个不同问题。放在某个目录的 conftest.py 中,会让该目录及其下级目录的测试可以发现它;但它究竟每个函数创建一次还是整场创建一次,仍由 scope 决定。
建议把 Fixture 放在最小的合理可见范围内:
- 只被一个模块使用的 Fixture,直接放在该测试模块中。
- 被一个业务目录复用的 Fixture,放在该目录的
conftest.py。 - 真正跨项目通用的基础设施,才放在测试根目录的
conftest.py。
pytest 安排 Fixture 时会考虑作用域、依赖关系和 autouse,而不是按函数在文件中的书写顺序。高作用域 Fixture 在同一请求链中通常会先于低作用域 Fixture 准备。与其依赖“看起来在上面”,不如把依赖写进参数:
import pytest
@pytest.fixture(scope="session")
def api_server():
# 整场复用已经启动的测试服务
server = start_test_server()
yield server
# 会话结束后关闭服务
server.stop()
@pytest.fixture
def authenticated_client(api_server):
# 参数依赖明确表达:先有服务,再创建本测试客户端
client = make_client(api_server)
client.login_as("test-user")
return client
性能检查:先测量,再扩大作用域
看到测试慢,不要先把所有 Fixture 都改成 session。慢点可能在业务代码、网络超时、数据工厂或重复迁移。pytest 自带耗时报告,可以先列出最慢用例:
# 列出最慢的 10 个测试,并显示超过 0.05 秒的阶段 pytest --durations=10 --durations-min=0.05
如果确认慢在 Fixture 初始化,可以在创建处加计数日志,或用 pytest 的 setup/teardown 输出观察调用频率。一次只调整一个资源,然后比较总耗时、最慢用例和失败稳定性。速度提升如果伴随随机失败,往往说明共享状态没有被正确清理。
还要注意一个容易误判的细节:pytest 会缓存当前作用域中的 Fixture 实例,但参数化 Fixture 在同一作用域内仍可能因不同参数而创建多次。因此,“session scope”不能被简单理解为“整个运行永远只调用一次”。
边界状态:参数化、动态作用域与清理
1. 参数化会形成多组缓存实例
import pytest
@pytest.fixture(scope="session", params=["sqlite", "postgresql"])
def backend(request):
# 每个参数值都需要自己的后端实例
instance = start_backend(request.param)
yield instance
# 对应参数实例使用结束后执行清理
instance.stop()
这类 Fixture 虽是 session scope,但测试会覆盖两种后端,因此不能假设只启动一个实例。估算性能时应把参数数量也计算进去。
2. 动态作用域适合显式运行模式
有时本地开发希望每个测试都重建容器,而 CI 希望整场复用。pytest 支持通过可调用对象动态返回 scope。这个可调用对象会在 Fixture 定义时执行一次,适合由命令行选项决定生命周期。
import pytest
def determine_scope(fixture_name, config):
# 显式传入 --keep-container 时整场复用,否则保持函数级隔离
return "session" if config.getoption("--keep-container") else "function"
@pytest.fixture(scope=determine_scope)
def service_container():
# scope 由运行参数决定,资源创建逻辑保持一致
container = start_container()
yield container
# 当前作用域结束后释放容器
container.stop()
动态 scope 不应变成隐藏的环境猜测。最好通过明确的命令行选项控制,并在 CI 配置中固定下来,否则开发机与流水线可能得到完全不同的隔离行为。
3. yield 清理必须与资源创建成对
作用域越大,清理遗漏的影响越大。推荐把创建和释放写在同一个 Fixture 的 yield 前后。若创建过程包含多个可能失败的步骤,应在每一步成功后登记对应清理,避免中途异常留下端口、进程或连接。
一张决策表选对 Fixture 作用域
| 资源特征 | 推荐策略 | 原因 |
|---|---|---|
| 便宜且可变 | function | 隔离收益远大于重复创建成本 |
| 昂贵且只读 | module / session | 可以安全减少初始化次数 |
| 昂贵且可回滚 | 大 scope 基础设施 + function 隔离层 | 同时获得复用和独立状态 |
| 只服务一个测试类 | class | 边界与使用范围一致 |
| 跨目录共享但不是全局 | package 或局部 conftest | 避免把可见性和生命周期放大到全项目 |
| 无法可靠清理的外部状态 | 优先 function 或唯一命名空间 | 不要用共享换取表面速度 |
最后可以记住一句话:scope 应该匹配资源生命周期,而不是匹配你希望测试有多快。先用 function 获得可信的隔离基线,再把确认昂贵、可安全复用的基础设施逐层提升到 module、package 或 session;任何共享可变状态,都必须有事务、重置、唯一命名空间或可靠清理作为补偿。这样优化后的测试套件,既快,也不会把偶发失败留给下一位维护者。
-
463 收藏
-
478 收藏
-
292 收藏
-
393 收藏
-
126 收藏
-
427 收藏
-
132 收藏
-
文章 · python教程 | 1天前 | 并发 · 异常处理 · python · asyncio · CancelledError 结构化并发 ExceptionGroup Python asyncio TaskGroup asyncio gather246 收藏
-
337 收藏
-
359 收藏
-
325 收藏
-
176 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习