
接手过一套越跑越慢的 pytest 接口测试用例从几十条加到两百多条之后跑完一轮从十几分钟一路膨胀到四十多分钟。当时第一反应是接口本身变慢结果用pytest --durations一查最耗时的根本不是接口请求而是一个每次用例都会重复执行的登录 fixture。这算是 Pytest Fixture 作用域最典型的坑fixture 会用但未必会用得对。function、class、module、session 四个作用域一旦选错轻则白跑一堆初始化代码把测试拖慢重则让用例之间互相污染跑得倒是快了失败得莫名其妙。这篇文章不打算讲文档里能查到的基础定义我想直接把 fixture 作用域背后的生命周期机制、四个作用域各自的适用边界、以及我在真实项目里踩过的三个坑讲清楚最后给出一套可以直接套用的选型策略。1. 先把作用域机制讲透fixture 到底在“作用”什么很多人对 fixture 的理解停留在“被装饰过的函数测试里声明参数就会自动调用”。这个理解不算错但会忽略一个关键事实fixture 不是每次调用都执行的普通函数而是带缓存的实例工厂。1.1 从“缓存实例”的角度理解 fixturepytest 内部为每个 fixture 维护了一个按作用域分层的缓存。当测试请求一个 fixture 时pytest 先看当前作用域下这个 fixture 有没有已经生成过的实例有就直接返回没有才去执行 fixture 函数本身并把返回值存到对应作用域的缓存里。作用域一结束缓存销毁teardown 逻辑执行。用伪代码来讲就是这样一个逻辑fixture_cache { function: {}, class: {}, module: {}, session: {}, } def get_fixture(name, scopefunction): if name in fixture_cache[scope]: return fixture_cache[scope][name] instance run_fixture_function(name) fixture_cache[scope][name] instance return instance这个模型能解释很多现象同一个测试函数里参数列表写了两次同一个 fixture实际上只执行一次因为“第一次执行后的结果已经缓存了”。function 作用域的 fixture每个测试用例都请求一次所以会执行 N 次。module 作用域的 fixture一个测试文件里所有测试只会触发一次文件执行完才做 teardown。session 作用域的 fixture整个 pytest 进程只执行一次进程结束才销毁。理解了这个缓存模型再看“怎么选作用域”这个问题本质上就是在回答两个问题这份数据能在这个范围内共享吗共享的成本和风险分别是什么1.2 作用域层级与 teardown 顺序pytest 的 fixture 作用域从低到高是这样排列的function class module package session低作用域包含在高作用域之内。一个 module 作用域的 fixture 里天然持有所有 function 作用域 fixture 的执行环境反过来function 作用域的实例生命周期只存在于单个用例内。teardown 顺序是一个常被忽略的细节。多个 fixture 嵌套时销毁顺序严格和创建顺序相反也就是“后创建的先销毁”。看这个例子import pytest pytest.fixture def outer(): print(outer setup) yield print(outer teardown) pytest.fixture def inner(outer): print(inner setup) yield print(inner teardown) def test_demo(inner): print(test run)执行顺序是outer setup → inner setup → test run → inner teardown → outer teardown。这个栈式顺序在写依赖多个 fixture 的清理逻辑时很重要尤其当 B fixture 的 teardown 依赖 A fixture 还存活时这个逆序能保证依赖关系不会断裂。2. 四种作用域的适用场景、代码示例与隐藏陷阱四种作用域不是简单的“越大越好”或者“越小越安全”。它们对应的是不同粒度的资源复用策略各有明确的适用场景和边界。2.1 function 作用域隔离性最强但“每次都执行”是有代价的这是 pytest 的默认作用域不写scope参数就是 function。pytest.fixture def random_user(): return create_random_user() def test_login_with_new_user(random_user): ... def test_create_order_with_new_user(random_user): ...适用场景很明确测试数据需要完全隔离每个用例都希望拿到独立的数据副本。fixture 创建的是可变对象而且后续测试逻辑会修改它。fixture 本身非常轻量比如生成一个 UUID、构造一个简单的数据字典。function 作用域最大的问题不是正确性而是性能。如果一个 fixture 的创建成本很高——比如发一次 HTTP 登录请求、建立一个数据库连接、编译一份正则规则——那么每用例执行一次就是纯浪费。我在开头提到的那个越跑越慢的测试套件就是登录 fixture 用了 function 作用域300 多个用例跑了 300 多次登录光这一步就占了总耗时的三分之二。2.2 class 作用域类内共享的“团队变量”class 作用域意味着同一个测试类内的所有测试方法共享同一个 fixture 实例。pytest.fixture(scopeclass) def account(): return Account(usernameadmin, roleadmin) class TestAccountOperations: def test_balance(self, account): assert account.balance 0 def test_transfer(self, account): account.transfer(amount100) assert account.balance ...class 作用域有一个很隐蔽的坑。类中的测试方法执行顺序是不固定的而所有方法又共享同一个account对象。如果test_balance先执行并且修改了余额test_transfer拿到的就是被改过的状态。这个“共享对象状态被前一个测试污染”的问题在 class 作用域下比 module 更隐蔽因为影响范围恰好是类内单独跑某一个测试方法时永远复现不了。我的建议是class 作用域只用来共享“只读对象”或者那种每次操作都会重新拉取状态的对象。如果测试逻辑本身会修改共享对象要么改成 function 作用域要么让 fixture 返回一个支持重置状态的对象。2.3 module 作用域只读资源共享的首选module 作用域是“一个测试文件内所有测试共享一个实例”。这个作用域在实际项目里是性价比最高的一个因为它既避免了 function 级别的重复创建开销又不会像 session 那样把共享范围扩大到整个进程。pytest.fixture(scopemodule) def config(): return load_config_from_file(test_config.yaml) def test_config_key_exists(config): assert timeout in config def test_config_limits(config): assert config[max_retries] 0读配置文件、加载测试环境参数、初始化只读的数据库连接池都是 module 作用域的典型场景。这些对象的特点是读取多、改动少、创建成本中等偏高。但要记住一个原则module 作用域下共享的对象尽量不要被测试修改。如果确实需要修改可以在 fixture 内部返回副本pytest.fixture(scopemodule) def user_ids(): base [1, 2, 3, 4, 5] return base.copy()这样即使某个测试往列表里追加或删除元素也只影响当前用例自己的副本。2.4 session 作用域重量级资源、只跑一次的代价与风险session 作用域的 fixture 在整个 pytest 会话期间只执行一次是所有作用域里复用程度最高的。pytest.fixture(scopesession) def admin_token(): return login(admin, password)典型的应用场景包括获取登录 token、启动外部测试服务、创建测试数据库结构、加载 AI 模型权重等。这些资源如果每个用例都重建成本完全不可接受。但 session 作用域的代价也很明确——一旦中间出了问题影响面是全部用例。我见过三个高频风险第一token 或连接在长时间运行中失效。一套测试跑一小时session fixture 在 30 分钟时创建的连接已经断开后面一半用例全部失败。解决方法是给 session fixture 增加“失效自动重连”的逻辑而不是简单的一次性创建。第二懒加载规则。session fixture 只有在被测试请求时才执行如果它定义在某个测试模块里而该模块的所有测试因为某种原因被跳过或丢弃那么其他模块里想用它的人也拿不到实例。第三也是我在第三部分要单独讲的——在 pytest-xdist 分布式执行下session 作用域并不是全局只执行一次而是每个 worker 各自执行一次。三种高开销场景的作用域选择对比资源类型建议作用域原因登录 tokensession登录成本高token 在有效期内可复用数据库连接module连接成本较高但跨文件共享有状态风险测试数据构造function数据隔离性优先避免用例间互相影响配置文件读取module只读共享几乎不涉及状态污染外部服务启动session启动成本极高且容器/进程可独立运行3. 选错作用域是踩坑重灾区三个真实排查案例理论说再多不如复盘真实踩过的坑。下面这三个案例分别对应可变共享对象、分布式执行、性能劣化三类问题都是我实际碰到并逐步排查出来的。3.1 案例一module 作用域的列表被测试修改后“数据污染”现象很诡异某个测试模块里一共 4 个用例单独跑任何一个都通过按顺序整个文件跑第 4 个用例必挂。一开始我怀疑是测试之间有状态依赖于是调整了用例书写顺序问题依然存在。排查到这里我开始怀疑是不是共享对象被修改了于是在测试里临时加了这样一段日志pytest.fixture(scopemodule) def user_list(): return [alice, bob, charlie] def test_remove_user(user_list): print(fbefore remove, id{id(user_list)}, list{user_list}) user_list.remove(bob) print(fafter remove, id{id(user_list)}, list{user_list}) def test_count(user_list): print(fchecking, id{id(user_list)}, list{user_list}) assert len(user_list) 3运行后日志里两个测试的id()完全一致test_count拿到的列表已经是删掉 bob 之后的状态。原因就是 module 作用域让整个测试文件共享了同一个列表对象前面的测试执行了修改操作后面的测试拿到了被污染的数据。修复方案是在 fixture 内部返回副本pytest.fixture(scopemodule) def user_list(): return [alice, bob, charlie].copy()这个案例给了我一个很深的印象共享可变对象是 fixture 作用域选择里最隐蔽的杀手。选 module 或 class 作用域之前一定要先问“这个对象会不会被测试修改”。如果会要么返回副本要么把作用域降到 function。3.2 案例二session 作用域 fixture 在 pytest-xdist 下被重复执行团队把接口自动化测试迁移到 pytest-xdist 分布式执行后日志里出现了一个奇怪的现象负责初始化测试环境的 session fixture 明明写的是scopesession但日志里“初始化完成”这句话整整打了 8 遍——正好对应 8 个 worker 进程。更麻烦的是初始化脚本里包含插入数据库的操作重复执行导致主键冲突一批 worker 直接报错。排查过程分三步。第一步先怀疑是不是 fixture 定义的位置问题。把 fixture 挪到根目录 conftest.py 后现象依旧。第二步查阅 pytest-xdist 的文档和源码才意识到问题的本质pytest-xdist 是起了多个 worker 进程来分担测试用例的每个 worker 都是独立的 Python 进程有自己独立的 fixture 缓存。session 作用域只能保证“单个进程内只执行一次”跨进程完全不生效。第三步解决方案是在初始化逻辑外加重入保护。当时用了filelock这个库from filelock import FileLock pytest.fixture(scopesession) def init_database(tmp_path_factory, worker_id): if worker_id master: # master 进程直接执行 do_init() else: # 其他 worker 等待文件锁保证只执行一次 with FileLock(init_db.lock): if not os.path.exists(init_db_complete.flag): do_init() Path(init_db_complete.flag).touch() yield这样不管起多少个 worker整个测试进程组里初始化逻辑只会执行一次。更省事的方案是把这种重量级初始化直接挪到 CI/CD 流水线的前置步骤pytest 只负责测试不负责搭环境。这个坑让我意识到作用域的“作用范围”不是想当然的那个范围。讨论 session 作用域时先要搞清楚自己跑测试的进程模型是单进程还是多进程。3.3 案例三function 作用域登录 fixture 让测试套件越跑越慢回到开头说的那个性能问题。当时 200 多个接口用例跑完要 40 多分钟一开始所有人都以为是接口性能下降了排查网络、排查服务端日志都没找到问题。后来我用pytest --durations20看了每个用例的耗时分布才发现最耗时的 10 个用例全是同一个原因——每个用例的前置时间里都有一次登录请求。登录功能做了 token 认证而 token 获取需要走一次完整的登录接口。登录 fixture 当时写的是pytest.fixture def admin_token(): return login(admin, password)function 作用域导致每个用例都重新登录一次。虽然有连接池但登录接口本身因为做了密码哈希和权限初始化单次耗时 0.3 到 0.5 秒200 个用例光登录就浪费了差不多 90 秒。这看起来不多但接口用例前置里还有造数据、查配置等操作叠加在一起就让整个套件慢得不可接受。后来改成pytest.fixture(scopesession) def admin_token(): token login(admin, password) yield token # 可以在这里做 token 失效后的清理改完以后登录次数从 200 多次降到了 1 次整个套件跑完从 43 分钟降到了 16 分钟。性能收益非常直接。但是同样要防一个事session 作用域下 token 可能在长时间运行中过期。如果用例跑太久某个时刻 token 失效后续用例全部 401。实际项目里我加了一个“失效自动重登”的工具函数pytest.fixture(scopesession) def admin_token(): token login(admin, password) def refresh(): nonlocal token token login(admin, password) return token yield token然后在请求封装里判断状态码遇到 401 就调用 refresh。具体逻辑不展开了核心思路是session 作用域不等于“创建一次就永远不管”它只是把创建频率降低不代表资源不会过期。4. 作用域之间的调用规则与容易被忽略的配置细节了解四个作用域各自的能力边界之后还要搞清楚作用域之间的依赖关系否则代码怎么写、写在哪都会踩坑。4.1 高作用域依赖低作用域会直接报错ScopeMismatchpytest 对 fixture 之间的作用域依赖有严格限制低作用域的 fixture 可以依赖高作用域的 fixture反之不行。也就是说一个 function 作用域的 fixture 可以请求 session 作用域的 fixture因为 session 实例已经存在并且生命周期覆盖 function。但一个 session 作用域的 fixture 不能请求 function 作用域的 fixture因为 session 实例是在整个会话开始时创建的那时候还没有任何 function 级别的资源存在。看这段代码pytest.fixture def user_data(): return {name: zhang} pytest.fixture(scopesession) def wrong_fixture(user_data): return transform(user_data)一旦某个测试请求wrong_fixturepytest 会直接报错ScopeMismatch: You tried to access the function scoped fixture user_data with a session scoped request object.这个错误信息的字面意思比较绕其实就是作用域依赖方向反了。解决方式很简单把user_data提升到 session 作用域或者把wrong_fixture降级到 function 作用域。实际项目中这个坑最常见于“把公共逻辑抽象到 session fixture但公共逻辑内部用了某个 function 级别的 helper”。设计 fixture 依赖时要先梳理清楚依赖树的作用域层级。4.2 conftest.py 的层级如何影响 fixture 作用域说 conftest.py 之前先说一个常见误解conftest.py 里的 fixture 不会因为写在 conftest 里就自动对测试生效还是要测试真正请求了它才会执行。这跟懒加载规则一致。conftest.py 的层级关系是根目录的 conftest.py 定义的 fixture 对整棵测试树可见子目录里的 conftest.py 定义的 fixture 只对该目录及其子目录下的测试可见。如果上下层 conftest 定义了同名 fixture离测试最近的那一层生效也就是“就近覆盖”。这个覆盖规则在做测试分层时很有用。比如根目录 conftest.py 里定义一个database的 session 级 fixture指向测试环境数据库某个子目录比如跑性能测试的目录可以放一个自己的 conftest.py定义同名的databasefixture指向专用的性能测试库。测试代码完全不用改pytest 会自动使用就近的 fixture。另一个容易被忽略的细节是 fixture 放在 conftest.py 还是测试模块里的区别。放在测试模块里意味着只有这个模块能看到放在 conftest.py 里意味着整个目录树共享。如果某个 fixture 未来很可能被多个模块使用建议直接放 conftest.py省得后面到处复制。4.3 params 参数化和 scope 的叠加效果fixture 可以配合params做参数化每个参数值都会让 fixture 在相应作用域上创建独立实例。pytest.fixture(scopemodule, params[mysql, postgresql]) def db_conn(request): conn connect(request.param) yield conn conn.close()这个 fixture 在每个测试模块里会执行两次——一次连接 mysql一次连接 postgresql。如果 scope 是 session那么整个会话中每种参数只执行一次。参数化和作用域叠加时要算清楚执行次数。一个 module 作用域的参数化 fixture参数有 3 个测试模块有 5 个那么 fixture 总共执行 15 次。如果这个 fixture 创建成本很高这个数字会直接拖慢测试套件。我见过有人在数据库连接 fixture 上用了 5 个参数结果每次跑测试光建连接就花了十几秒。参数化 fixture 的 teardown 也会跟着每个参数实例执行多次。清理逻辑必须写成幂等的否则重复清理会报错。比如数据库里删除某条记录第二次删除时记录已经不存在就会抛异常。稳妥做法是清理前先做存在性判断。5. 动态作用域、间接参数化和更灵活的设计有些场景下你不能预先确定某个 fixture 应该用哪个作用域或者希望同一个 fixture 在普通测试和高损耗测试中表现不同。这时可以借助request.scope和request.getfixturevalue做动态处理。5.1 用 request.scope 让 fixture 内部感知调用方的作用域fixture 函数参数里加一个request就能拿到当前请求的 scopepytest.fixture def connection(request): if request.scope module: conn get_module_level_conn() else: conn get_function_level_conn() yield conn conn.close()这样写的好处是调用方的测试即使不在同一个作用域级别也能通过同一个名字拿到适合当前场景的资源。比如一个函数级测试调用connection拿到的就是轻量专用连接一个模块级测试调用同一个名字拿到的就是长连接。对于资源类型丰富、环境复杂的项目这种方式能减少 fixture 数量。需要注意request.scope拿到的是“当前请求方”的作用域不一定是 fixture 自身 scope。如果 fixture 自身声明为scopesession那么无论谁请求它request.scope都是 session。5.2 动态请求其他 fixturerequest.getfixturevaluefixture 内部可以通过request.getfixturevalue按名称动态获取其他 fixture。这在写“分流器”类 fixture 时很常用pytest.fixture def data_provider(request): if request.param heavy: value request.getfixturevalue(module_data) else: value request.getfixturevalue(function_data) return value配合间接参数化测试可以自己决定要用哪套数据准备策略pytest.mark.parametrize(data_provider, [heavy, light], indirectTrue) def test_something(data_provider): ...这种方式的实用场景是同一套测试在冒烟测试和全量回归里希望使用不同的数据准备策略。冒烟测试用轻量 function 级数据跑得快全量回归用重量级 module 级数据覆盖更广。测试函数不需要感知具体实现只要声明data_provider参数即可。5.3 fixture 工厂模式把“选择权”交给测试还有一种设计思路是把 fixture 定义成“工厂”fixture 本身不做任何创建动作只返回一个函数由测试在需要的时候自行调用。这种模式非常适合测试内需要多次创建不同参数的场景。pytest.fixture def create_user(): fixture 工厂返回一个创建用户的函数。 def _create_user(name, rolenormal): return User(namename, rolerole) return _create_user def test_admin_create_user(create_user): admin create_user(admin, roleadmin) normal create_user(zhang) ...工厂模式看似绕了一圈实际好处是提高了测试内逻辑的表达力测试可以按需多次创建对象而不必担心 fixture 实例被复用。工厂函数本身可以直接放在 session 或 module 级 fixture 里因为返回的函数是无状态的。6. 一个接口自动化项目的 fixture 作用域选型复盘前面把原理、坑和高级用法都说了最后放一个相对完整的选型复盘。这是我最近一次给接口自动化测试项目整理 fixture 体系时的真实决策过程希望能给正在搭建或重构测试框架的人一个可以直接参考的模板。6.1 项目背景与当时的痛点项目是一个 To B 产品的接口自动化测试套件规模在 200 个用例左右。主要资源依赖包括管理后台登录 token、被测服务的数据库连接、每个用例独立的测试账号、以及一个需要远程同步的环境初始化脚本。改造前的 fixture 几乎全是默认的 function 作用域导致的结果是登录 token 每个用例都重新获取、数据库连接每个用例都重新建立、测试账号每次都用固定账号导致用例间数据相互干扰。整个套件跑完 43 分钟稳定性也很差经常有莫名其妙的失败。6.2 最终选定的作用域组合与理由fixture作用域选择理由admin_tokensession登录成本高token 有效期足够覆盖整个用例周期封装了 401 自动重登db_connmodule数据库连接成本较高模块内共享不允许跨模块共享防止连接状态被意外修改test_userfunction每个用例需要独立的测试账号避免数据隔离问题time_snapshotfunction记录的是用例执行的具体时间每次执行都应该独立remote_env_initsession初始化远端测试环境成本极高整个会话执行一次用文件锁兼容 xdist 分布式执行config_datamodule配置文件是只读数据模块内共享即可不必每次重新加载这个组合的逻辑其实很朴素越重量级的资源越往高作用域放越容易被测试修改的数据越往低作用域放。token、连接、环境初始化属于前者独立测试账号和时间戳属于后者。6.3 实际收益与个人体会改造后同一套用例跑完从 43 分钟降到了 16 分钟因为重复登录和重复建连的消耗被彻底去掉了。稳定性方面也有明显提升因为每个用例拿到独立测试账号后几乎不再有“上一个用例留下的脏数据导致当前用例失败”的情况。我个人在事后复盘时养成了一个习惯每次新加一个 fixture先问自己三个问题。第一这个 fixture 的创建成本高不高如果它只是拼个字符串、读个小文件function 作用域没有任何问题如果它要调接口、建连接、初始化环境请认真考虑 module 或 session。第二共享出去之后测试代码会不会修改它如果是可变对象且会被修改要么返回副本要么用 function 作用域如果对象本来就是只读的module 甚至 session 都没问题。第三如果它中途失效了影响面有多大function 级别的 fixture 失效只影响一个用例session 级别的 fixture 失效可能让整个套件报废。对长耗时运行的资源一定要做失效重试或自动重建机制。把这三个问题想清楚作用域大概率就不会选错。作用域本身没有绝对的优劣只有“当前场景下合不合适”。希望这篇内容能让你下次再面对 fixture 时不是凭感觉拍一个 scope 参数而是真正基于资源成本、数据隔离和故障影响做一次完整的权衡。