文章目录
-
- [1. 什么是 fixture作用域](#1. 什么是 fixture作用域)
- [2. 核心问题:为什么需要作用域?](#2. 核心问题:为什么需要作用域?)
- [3. fixture 的作用域层级](#3. fixture 的作用域层级)
- [4. 什么时候用哪种 scope](#4. 什么时候用哪种 scope)
- [5. 五种作用域类型代码举例](#5. 五种作用域类型代码举例)
- [6. 作用域实际应用场景举例](#6. 作用域实际应用场景举例)
- [7. 作用域的缓存(cache)与重用](#7. 作用域的缓存(cache)与重用)
- [8. 作用域依赖规则(防止悬挂引用)](#8. 作用域依赖规则(防止悬挂引用))
- [9. 销毁(teardown)时机](#9. 销毁(teardown)时机)
- [10. 调试技巧](#10. 调试技巧)
- [11. 易错点](#11. 易错点)
- [12. 动态作用域](#12. 动态作用域)
- [13. fixture 依赖链的创建与销毁顺序](#13. fixture 依赖链的创建与销毁顺序)
- [14. 参数化 fixture 在非 function scope 下的情况](#14. 参数化 fixture 在非 function scope 下的情况)
1. 什么是 fixture作用域
fixture作用域决定了fixture的生命周期,即它何时被创建、何时被销毁,以及在多个测试之间如何共享。合理使用作用域可以显著提高测试效率,特别是对于那些创建成本高昂的资源(如网络连接、数据库连接等)。
2. 核心问题:为什么需要作用域?
想象一下测试一个需要连接数据库的网站。如果没有作用域控制,你的测试代码可能会这样:
python
# 低效的做法:每个测试函数都创建和关闭一次连接
def test_user_login():
db_connection = create_db_connection() # 耗时操作
# ... 测试登录逻辑 ...
db_connection.close()
def test_create_post():
db_connection = create_db_connection() # 再次执行耗时操作
# ... 测试发帖逻辑 ...
db_connection.close()
问题显而易见 :每个测试函数都重复执行了耗时的"创建连接"和"关闭连接"操作。如果我有100个测试,就会创建和关闭100次连接,大部分时间都浪费在了准备工作上,而不是真正的测试逻辑。
解决方案:使用 fixture 作用域。我们可以让多个测试函数共享同一个数据库连接。
3. fixture 的作用域层级
pytest 提供了 5 个层级的作用域,从细粒度到粗粒度排列:
作用域 | 英文 | 销毁时机 | 适用场景 |
---|---|---|---|
function | 函数 | fixture 在每个测试函数开始时创建,每个测试函数结束时 | 默认作用域。适用于独立的、无状态的测试。例如,一个临时文件。 |
class | 类 | 每个测试类中最后一个测试方法结束时 | 当一个类中的所有测试方法都需要同一个 fixture 时。 |
module | 模块 | 每个测试模块(.py 文件)中最后一个测试函数结束时 | 非常适合数据库连接、SMTP 连接等,模块内所有测试共享一个。 |
package | 包 | 每个测试包(目录)中最后一个测试模块结束时 | 当一个目录下的所有测试文件都需要共享一个配置或资源时,fixture 通常放在该包的 conftest.py。 |
session | 会话 | 整个 pytest 运行会话结束时 | 最高级的共享。例如,启动一个全局的 Docker 容器、初始化一个所有测试都能读的缓存。 |
4. 什么时候用哪种 scope
- function:默认且最安全。当 fixture 返回可变数据或会被测试改写时,首选 function。
- class:多个测试方法在同一个测试类中共享某个对象(例如一个浏览器实例或 DB transaction),且你希望在类级别复用。
- module:模块内部多个测试函数都需要链接到同一个昂贵对象(例如一个网络服务连接)。
- package:跨多个模块/子包需要共享资源(较少用,但适合在包级别启动/关闭资源)。
- session:全局昂贵资源(比如启动一个外部 DB 容器、启动 mock server、初始化一次性 heavy fixture)。
5. 五种作用域类型代码举例
function(默认作用域)
python
@pytest.fixture # 等同于 @pytest.fixture(scope="function")
def sample_fixture():
print("创建fixture")
return "test_data"
def test_one(sample_fixture):
print(f"测试1使用: {sample_fixture}")
def test_two(sample_fixture):
print(f"测试2使用: {sample_fixture}")
特点:
- 每个测试函数都会创建新的fixture实例
- 测试函数结束后立即销毁
- 适用于轻量级、无状态的fixture
class(类级作用域)
python
@pytest.fixture(scope="class")
def database_connection():
print("建立数据库连接")
conn = create_db_connection()
yield conn
print("关闭数据库连接")
conn.close()
class TestUserOperations:
def test_create_user(self, database_connection):
# 使用同一个数据库连接
pass
def test_update_user(self, database_connection):
# 使用同一个数据库连接
pass
class TestProductOperations:
def test_create_product(self, database_connection):
# 这里会创建新的数据库连接
pass
特点:
- 同一个测试类中的所有测试方法共享同一个fixture实例
- 类中最后一个测试完成后销毁
- 适用于需要在类级别共享状态的场景
module(模块级作用域)
SMTP连接例子:
python
# conftest.py
import smtplib
import pytest
@pytest.fixture(scope="module")
def smtp_connection():
print("创建SMTP连接")
return smtplib.SMTP("smtp.gmail.com", 587, timeout=5)
# test_module.py
def test_ehlo(smtp_connection):
response, msg = smtp_connection.ehlo()
assert response == 250
assert b"smtp.gmail.com" in msg
def test_noop(smtp_connection):
response, msg = smtp_connection.noop()
assert response == 250
关键观察点: 从测试输出可以看到:
python
smtp_connection = <smtplib.SMTP object at 0xdeadbeef0001>
两个测试函数收到的是完全相同的对象实例(相同的内存地址),证明fixture被复用了。
特点:
- 整个测试模块中的所有测试函数共享同一个fixture实例
- 模块中最后一个测试完成后销毁
- 显著提高包含网络操作等耗时fixture的测试效率
package(包级作用域)
python
# 项目结构
myproject/
├── conftest.py
├── tests/
│ ├── __init__.py
│ ├── test_module1.py
│ └── subpackage/
│ ├── __init__.py
│ └── test_module2.py
# conftest.py
@pytest.fixture(scope="package")
def shared_resource():
print("创建包级共享资源")
return expensive_resource()
特点:
- 包及其所有子包、子目录中的测试共享同一个fixture
- 包中最后一个测试完成后销毁
- 适用于需要跨多个模块共享的重量级资源
session(会话级作用域)
python
@pytest.fixture(scope="session")
def smtp_connection():
print("创建会话级SMTP连接")
# 整个测试会话期间只创建一次
return smtplib.SMTP("smtp.gmail.com", 587, timeout=5)
特点:
- 整个pytest测试会话期间只创建一次
- 所有测试文件、测试函数都共享同一个实例
- 测试会话结束时销毁
- 适用于最重量级的资源,如大型数据集、复杂的服务启动等
6. 作用域实际应用场景举例
python
# conftest.py
import smtplib
import pytest
@pytest.fixture(scope="module") # 关键在这里!
def smtp_connection():
# 这个函数现在每个测试模块只会被调用一次
return smtplib.SMTP("smtp.gmail.com", 587, timeout=5)
python
# test_module.py
def test_ehlo(smtp_connection):
# 这个 smtp_connection 和 test_noop 中的是同一个对象
response, msg = smtp_connection.ehlo()
assert response == 250
def test_noop(smtp_connection):
# 这里直接使用已创建的连接,无需再次建立
response, msg = smtp_connection.noop()
assert response == 250
运行代码:
python
$ pytest test_module.py
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 2 items
test_module.py FF [100%]
================================= FAILURES =================================
________________________________ test_ehlo _________________________________
smtp_connection = <smtplib.SMTP object at 0xdeadbeef0001>
def test_ehlo(smtp_connection):
response, msg = smtp_connection.ehlo()
assert response == 250
assert b"smtp.gmail.com" in msg
> assert 0 # for demo purposes
^^^^^^^^
E assert 0
test_module.py:7: AssertionError
________________________________ test_noop _________________________________
smtp_connection = <smtplib.SMTP object at 0xdeadbeef0001>
def test_noop(smtp_connection):
response, msg = smtp_connection.noop()
assert response == 250
> assert 0 # for demo purposes
^^^^^^^^
E assert 0
test_module.py:13: AssertionError
========================= short test summary info ==========================
FAILED test_module.py::test_ehlo - assert 0
FAILED test_module.py::test_noop - assert 0
============================ 2 failed in 0.12s =============================
运行流程:
- 当 pytest 开始执行 test_module.py 时,它发现第一个测试 test_ehlo 需要 smtp_connection。
- 由于这是该模块第一次请求该 fixture,且作用域是 module,pytest 会执行 smtp_connection() 函数,创建并返回一个 SMTP 连接对象。
- pytest 将这个对象缓存起来,并与当前模块关联。
- test_ehlo 使用这个连接对象执行测试。
- 接着执行 test_noop,它同样请求 smtp_connection。
- pytest 检查到在当前模块的缓存中已经存在一个 smtp_connection 实例,于是直接将其注入到 test_noop 中,而不会再次执行创建函数。
- 当 test_module.py 中所有测试都执行完毕后,pytest 会销毁这个缓存的连接对象。
证据:从上面的输出中可以看到两个测试失败信息里,smtp_connection 的内存地址是相同的:<smtplib.SMTP object at 0xdeadbeef0001>。这证明了它们使用的是同一个对象。
conftest.py 的角色
为什么要把 fixture 放在一个叫 conftest.py 的文件里?
- 自动发现:pytest 会自动发现项目目录及其子目录下的所有 conftest.py 文件,并将其中的 fixture 加载为插件。
- 作用域共享:一个 conftest.py 文件中定义的 fixture,可以被同一目录及其所有子目录下的所有测试文件使用。
- 层级结构:你可以在不同的目录层级放置多个 conftest.py。子目录中的 fixture 可以覆盖父目录中同名的 fixture。这提供了强大的配置继承和覆盖能力。
项目结构示例:
python
project_root/
├── conftest.py # 这里定义的 fixture (scope=session) 对整个项目有效
├── tests/
│ ├── conftest.py # 这里定义的 fixture (scope=module) 对 tests/ 下所有文件有效
│ ├── test_db.py
│ └── test_api.py
└── src/
7. 作用域的缓存(cache)与重用
- pytest 会缓存 fixture 的返回值,并在相同 scope(以及相同参数值)下重用它,从而节省昂贵的初始化成本(例如数据库连接、外部容器、启动服务器等)。
- 缓存的 key 会包含 fixture 的名字、scope 及参数(若 fixture 被参数化)。因此参数化 fixture 会产生"每个参数值一份实例"的效果。
- pytest 只在当前有效组合上缓存一个实例(即缓存基于 scope+参数)。因此在参数化或交错运行测试时,看起来 fixture 可能被"多次调用"。
8. 作用域依赖规则(防止悬挂引用)
- 如果 fixture A 依赖(request)fixture B,则 B 的生命周期(scope)必须与 A 一样大或更长(更"持久")。
换句话说:一个长期生命周期的 fixture 不能依赖短生命周期的 fixture。
举例:session 不能依赖 function(会触发 ScopeMismatchError)。 - 直观理解:依赖的 fixture 必须不会比依赖它的 fixture 更快被销毁,否则依赖对象会悬空。
9. 销毁(teardown)时机
fixture 在其 scope 结束时被销毁(yield 后的清理代码或 request.addfinalizer 执行),并且销毁顺序是 依赖的反向顺序(先创建的后销毁)。
10. 调试技巧
- Traceback 中会显示 fixture 对象(比如 <smtplib.SMTP object at 0xdeadbeef>),可以据此确认是否复用了同一实例。
- 重现 ScopeMismatchError:通常错误信息会明确指出哪个 fixture 的 scope 与依赖不匹配(例如 ScopeMismatch: You tried to access the 'function' scoped fixture 'xyz' with a 'session' scoped fixture 'abc')。按错误提示调整 scope 或改为工厂。
- 临时降低复用:把某个 fixture 的 scope 改为 function 看是否消失(用作排查)。
- 在 teardown/log 输出里打印 id(obj) 或内存地址,便于判断是不是同一对象被复用。
11. 易错点
ScopeMismatchError(最常见错误之一)
- 错误示例:session fixture 依赖 function fixture → pytest 会报 ScopeMismatchError。
- 解决办法:
- 提升被依赖 fixture 的 scope(e.g. 把 function 改为 session/module),或
- 将被依赖对象改为工厂模式(把短生命周期的对象由长生命周期的 fixture 的返回"工厂"来创建),或
- 改变设计:不要让长期 fixture 直接依赖短期 fixture。
共享可变对象导致测试间污染
session/module 级别共享可变对象(例如 dict、list、数据库表句柄)会让测试互相影响。解决:返回不可变结构或在 function 级别返回拷贝,或使用工厂创建隔离实例。
并行测试(xdist)与 session scope
使用 pytest-xdist -n 时,每个 worker 是独立进程;每个 worker 都会创建自己的 session-scoped fixture。不要假设 session scope 在所有并行 worker 间共享。
参数化 + scope 的交互
如果 fixture 参数化(@pytest.fixture(params=[...])),pytest 会为每个参数值创建独立的实例(在指定 scope 内)。因此参数化会增加 fixture 被调用的次数(每个参数一次),即使 scope 不是 function。
Teardown 的时机
如果你在 fixture 中打开 socket、文件或外部进程,一定要在 yield 后或 addfinalizer 中正确释放它们;否则可能出现资源泄露(端口占用、描述符泄露)。清理代码放在 yield 之后。pytest 会在该 fixture 作用域结束时执行清理代码。
python
@pytest.fixture(scope="module")
def smtp_connection():
connection = smtplib.SMTP("smtp.gmail.com", 587, timeout=5)
yield connection # 测试使用这个连接
connection.close() # 模块内所有测试结束后,执行关闭
conftest.py 放置位置与可见性
把 fixture 放在 conftest.py,pytest 会向下递归查找并加载:子目录/模块中的测试都能访问到上级目录的 conftest.py 中定义的 fixture。把 conftest.py 放在项目根或包根能让多个子模块共享。
fixture 覆盖优先级
如果在测试模块中定义了与 conftest.py 同名的 fixture,模块内定义会优先使用(局部覆盖全局)
autouse=True
@pytest.fixture(scope="module", autouse=True)
会自动为模块中所有测试执行(无需显式传入参数)。适合做隐式全局 setup/teardown(谨慎使用,容易隐藏依赖)。
12. 动态作用域
pytest 允许你在运行时根据条件(如命令行参数)来决定一个 fixture 的作用域。
案例:控制 Docker 容器的生命周期
python
def determine_scope(fixture_name, config):
# 如果用户在命令行中指定了 --keep-containers
if config.getoption("--keep-containers", None):
# 那么容器在整个测试会话中都保持,用于调试
return "session"
else:
# 否则,每个测试函数都新建并销毁一个容器
return "function"
@pytest.fixture(scope=determine_scope) # 传入一个可调用对象!
def docker_container():
container = spawn_container()
yield container
container.stop() # 根据动态决定的作用域,在适当的时候执行清理
运行方式:
- pytest tests/:Docker 容器会为每个测试函数创建和销毁(function 作用域)。
- pytest tests/ --keep-containers:Docker 容器会在整个测试会话中只创建一次,并被所有测试共享(session 作用域),方便你测试后进去检查状态。
13. fixture 依赖链的创建与销毁顺序
fixture A → 依赖 B → 依赖 C,pytest 会按依赖链从最底部创建并在反向销毁。
创建顺序:自底向上
- 如果 fixture A 依赖 B,B 又依赖 C,pytest 在调用 A 前会先去确保 C → B → A 都准备好了。
- 也就是说 pytest 会先构建依赖链的最底层(C),再一层层往上创建。
销毁顺序:自顶向下(反向销毁)
- pytest 在测试结束时,会按照创建的逆序来清理 fixture。
- 这保证了:如果 A 依赖 B,那么在销毁时,先销毁 A(避免 A 还需要 B 时 B 已被销毁),再销毁 B,最后销毁 C。
直观比喻
- 就像搭积木:要放上顶层积木 A,必须先放 C(底座)和 B(中间层)。
- 拆的时候要反着来:先拿掉 A,再拿掉 B,最后才能移掉 C。
python
import pytest
@pytest.fixture
def fixture_c():
print("\n[SETUP] fixture_c")
yield "C"
print("[TEARDOWN] fixture_c")
@pytest.fixture
def fixture_b(fixture_c):
print("\n[SETUP] fixture_b (depends on C)")
yield f"B uses {fixture_c}"
print("[TEARDOWN] fixture_b")
@pytest.fixture
def fixture_a(fixture_b):
print("\n[SETUP] fixture_a (depends on B)")
yield f"A uses {fixture_b}"
print("[TEARDOWN] fixture_a")
def test_example(fixture_a):
print(f"Running test with: {fixture_a}")
执行结果(输出)
python
[SETUP] fixture_c
[SETUP] fixture_b (depends on C)
[SETUP] fixture_a (depends on B)
Running test with: A uses B uses C
[TEARDOWN] fixture_a
[TEARDOWN] fixture_b
[TEARDOWN] fixture_c
观察:
- 创建顺序:C → B → A
- 测试运行时使用最顶层的 fixture A
- 销毁顺序:A → B → C(完全逆序)
应用场景
- 数据库测试:
- C:数据库连接(全局一份)
- B:开启事务
- A:在事务中初始化数据
- 测试执行后,先清理数据,再回滚事务,最后关闭连接。
- Web 测试:
- C:启动 Selenium server
- B:启动浏览器驱动
- A:打开网页并登录
- 测试执行后,先关闭页面,再关闭浏览器,最后关闭 server。
14. 参数化 fixture 在非 function scope 下的情况
- 参数化 fixture (params=[...])
- pytest 会为 fixture 的 每个参数值 都生成一个独立的实例。
- 换句话说,params=[1,2] → pytest 会生成两份 fixture:一份参数值=1,一份参数值=2。
- scope 控制实例的生命周期
- fixture 的 scope 决定这份实例能被多少个测试共享。
- function:每个测试函数都会重新生成参数值的实例。
- module:同一个模块里所有测试函数,参数值相同的情况会共享同一个实例。
- session:整个 test session 内,参数值相同的情况都只生成一次。
- 直观理解
- param 是横向扩展:增加"多少份实例"。
- scope 是纵向延伸:控制"每份实例能活多久、被谁共享"。
示例代码
python
import pytest
@pytest.fixture(scope="module", params=[1, 2])
def my_fixture(request):
print(f"\n[SETUP my_fixture] param={request.param}")
yield request.param
print(f"[TEARDOWN my_fixture] param={request.param}")
def test_a(my_fixture):
print(f"test_a using {my_fixture}")
def test_b(my_fixture):
print(f"test_b using {my_fixture}")
执行结果
python
[SETUP my_fixture] param=1
test_a using 1
test_b using 1
[TEARDOWN my_fixture] param=1
[SETUP my_fixture] param=2
test_a using 2
test_b using 2
[TEARDOWN my_fixture] param=2
- 参数化行为:
- params=[1,2] → pytest 会跑两轮测试(1 和 2 各一轮)。
- 这相当于把 test_a 和 test_b 各自复制两份:
- test_a[1]、test_a[2]
- test_b[1]、test_b[2]
- scope=module 行为:
- 每一轮参数值固定时,模块内的多个测试共享同一个实例。
- 比如 param=1 时:test_a[1] 和 test_b[1] 用的都是同一个 fixture 实例。
- 等这一轮结束后才 teardown,再进入 param=2 的那一轮。
- 如果 scope=function:
- 那么 test_a[1] 和 test_b[1] 会各自独立创建一次 param=1 的实例(不会共享)。
- teardown 也在每个函数后立即发生。
应用场景
- scope=function + params
- 适合测试用例之间必须完全独立,不能共享状态(例如测试不同的数据库用户账号)。
- scope=module + params
- 适合一组测试都要针对某个配置跑一遍(比如同一个 API,要测试 json 格式和 xml 格式)。
- 每个配置只需要初始化一次,模块内复用,节省时间。
- scope=session + params
- 适合全局昂贵的资源(例如 Docker 容器,param 控制不同镜像),整个测试过程只初始化一次,所有模块共享。
码了很多字,辛苦啦~ 如果你能坚持看到这里。
关于 pytest fixtures 作用域还有疑问的话请给博主留言一起探讨,蟹蟹!!!