[pytest] 一文掌握 fixture 的作用域(scope)机制

文章目录

    • [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 =============================

运行流程:

  1. 当 pytest 开始执行 test_module.py 时,它发现第一个测试 test_ehlo 需要 smtp_connection。
  2. 由于这是该模块第一次请求该 fixture,且作用域是 module,pytest 会执行 smtp_connection() 函数,创建并返回一个 SMTP 连接对象。
  3. pytest 将这个对象缓存起来,并与当前模块关联。
  4. test_ehlo 使用这个连接对象执行测试。
  5. 接着执行 test_noop,它同样请求 smtp_connection。
  6. pytest 检查到在当前模块的缓存中已经存在一个 smtp_connection 实例,于是直接将其注入到 test_noop 中,而不会再次执行创建函数。
  7. 当 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_a1、test_a2
      • test_b1、test_b2
  • scope=module 行为:
    • 每一轮参数值固定时,模块内的多个测试共享同一个实例。
    • 比如 param=1 时:test_a1 和 test_b1 用的都是同一个 fixture 实例。
    • 等这一轮结束后才 teardown,再进入 param=2 的那一轮。
  • 如果 scope=function:
    • 那么 test_a1 和 test_b1 会各自独立创建一次 param=1 的实例(不会共享)。
    • teardown 也在每个函数后立即发生。

应用场景

  • scope=function + params
    • 适合测试用例之间必须完全独立,不能共享状态(例如测试不同的数据库用户账号)。
  • scope=module + params
    • 适合一组测试都要针对某个配置跑一遍(比如同一个 API,要测试 json 格式和 xml 格式)。
    • 每个配置只需要初始化一次,模块内复用,节省时间。
  • scope=session + params
    • 适合全局昂贵的资源(例如 Docker 容器,param 控制不同镜像),整个测试过程只初始化一次,所有模块共享。

码了很多字,辛苦啦~ 如果你能坚持看到这里。

关于 pytest fixtures 作用域还有疑问的话请给博主留言一起探讨,蟹蟹!!!

相关推荐
默_笙4 天前
🍙 给每个请求过安检:FastAPI 是怎么把校验写进类型注解的
python
qq_426003964 天前
启动playwright录制codegen生成自动化测试脚本
python·自动化
虎头金猫4 天前
4K 视频总卡在公网带宽?用 N1 + OpenList 把网盘播放链路重新理顺
运维·服务器·网络·python·容器·beautifulsoup·pandas
长沙三为智能科技4 天前
家政小程序开发从0到上线:五阶段交付流程与验收清单
python
伞伞悦读4 天前
【第38期】Python 模块与包详解:import、from、模块搜索路径、包结构和 __init__
开发语言·python
只睡四小时4 天前
Canvas 弹道联机实战:700 行 + 固定时间步长
python·websocket·html5·游戏开发·canvas
奇思妙想聪明勤奋的小羊4 天前
DeepAgents第5章:子Agent 与上下文隔离—让 Agent学会委派
人工智能·python·学习·语言模型
lpfasd1234 天前
2026年第38周GitHub趋势周报
python·科技·github
IZero074 天前
Jev 与 Laya
python·语言模型