[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_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 作用域还有疑问的话请给博主留言一起探讨,蟹蟹!!!

相关推荐
Script kid2 小时前
Pytest框架速成
数据库·pytest
Cherry Zack2 小时前
Django 视图与路由基础:从URL映射到视图函数
后端·python·django
Leinwin2 小时前
Codex CLI 配置 Azure OpenAI GPT-5-codex 指南
后端·python·flask
之歆3 小时前
LangGraph构建多智能体
人工智能·python·llama
闲人编程3 小时前
告别Print: Python调试入门,用PDB高效找Bug
开发语言·python·bug·调试·pdb·断点设置
AI量化投资实验室3 小时前
年化422%,回撤7%,夏普比5.4| Deap因子挖掘新增qlib因子库,附python代码
开发语言·python
站大爷IP3 小时前
Python爬取微博热搜并实时发送到邮箱:零基础实现指南
python
胖墩会武术3 小时前
大模型效果优化方案(经验分享)
人工智能·经验分享·python·语言模型
IvanCodes3 小时前
PySpark 安装教程及 WordCount 实战与任务提交
大数据·python·spark·conda