pytest fixture 作用域全解析:从 function 到 session 如何选

pytest fixture 作用域全解析:从 function 到 session 如何选 1. 从一次“测试互相污染”说起为什么需要搞清楚 fixture 作用域如果你是刚开始用 pytest 写自动化测试大概率见过这种诡异现象单个测试文件单独跑全绿一旦整个测试套件一起跑就莫名其妙挂掉几单。翻遍代码断言也没写错数据也没动过最后折腾半天发现罪魁祸首是 fixture 里的“共享状态”被上一个测试用例改了或者是某个耗时的 fixture 被每个测试函数重复执行了一遍导致整个套件跑完要多等十几分钟。这两个问题的根源其实都指向同一个概念——pytest fixture 的作用域scope。fixture 是 pytest 最核心的利器帮我们把测试前置条件、数据准备、环境初始化、清理动作全部封装成可复用的函数。但 fixture 到底多久创建一次、多久销毁一次由谁说了算答案就是它的scope参数。取值有四种function、class、module、session对应四种不同的生命周期策略。选对了测试又快又稳选错了轻则测试互相污染、用例执行顺序依赖重则并发执行时数据错乱、环境残留。这篇内容是我在实际项目中反复踩坑后的一次完整梳理适合刚上手 pytest 的测试开发、以及正在维护大型自动化测试套件但对 fixture 生命周期还有点模糊的同学。我不会只把官方文档翻译一遍而是结合接口自动化、UI 自动化和数据处理的真实场景把每个作用域的行为机制、销毁时机、选型依据、常见坑都拆开讲清楚。看完你不仅知道“怎么选”还能明白“为什么这么选”回头跟同事聊 fixture 的时候也能讲出点门道来。2. fixture 的本质不要把它当成普通的“前置函数”2.1 一段最普通的 fixture 代码背后发生了什么先看一个最常见的写法import pytest pytest.fixture def user_token(): token login(test_user, 123456) return token def test_get_user_info(user_token): resp get_user_info(user_token) assert resp.status_code 200这段代码很简单user_token就是一个 fixture。但要注意scope没写pytest 默认取function也就是说每个测试用例调用这个 fixture 时都会执行一次login(test_user, 123456)。如果测试类里有 5 个测试方法每个方法都用到user_token那登录就得执行 5 次。如果登录接口本身只要 200ms5 次也就 1 秒问题不大但如果这个接口背后要生成一套完整的环境数据、要拉取授权、要预热缓存一次 3 秒5 次就是 15 秒这套件一多整个回归时间就上去了。更麻烦的是如果登录行为本身有副作用——比如每次登录都会往数据库写一条会话记录或者调用了某个外部计费接口——那重复执行的代价就不只是时间还有可能造成脏数据。所以理解 fixture 的第一步是把它看成一个“带缓存的依赖注入器”而不是简单的“前置步骤函数”。它有几个关键特征我一个个说。2.2 fixture 的懒加载与依赖注入fixture 是懒加载的也就是说一个测试用例只要不把 fixture 名写进参数列表或者没被其他 fixture 依赖pytest 压根不会去执行它。写完一堆 fixture 但没用上不会产生任何额外开销。fixture 可以依赖其他 fixturepytest 会按依赖关系自动解析执行顺序。比如pytest.fixture def db_conn(): conn create_connection() yield conn conn.close() pytest.fixture def user_record(db_conn): return db_conn.insert_user(nametest, age18) def test_query_user(user_record): assert query_user(user_record[id]).name test这里test_query_user只显式声明依赖user_record但 pytest 发现user_record依赖db_conn所以会先创建连接、再插用户、最后跑测试。这一整套依赖关系链就是 pytest fixture 最强大的地方每个测试函数只声明自己真正需要的东西剩余组装工作全部交给框架。2.3 yield 分割了“前置”和“后置”fixture 的销毁逻辑是围绕yield展开的。yield之前的代码是 setupyield之后的是 teardown。看个例子pytest.fixture def user(): print(创建用户) u create_user() yield u print(清理用户) delete_user(u.id)测试跑完后pytest 会自动执行yield后面的清理代码。这里需要理解的是fixture 返回的时机和清理的时机由作用域决定。不是每个测试跑完就立刻清理而是要看 fixture 属于哪种作用域。所以到这里scope 的定位就很清楚了它决定了“这一个 fixture 实例”能活多久何时创建、何时被缓存复用、何时触发 teardown。搞懂了这一层后面四种作用域的行为就顺理成章了。3. 四种作用域逐个拆解生命周期、执行次数与销毁时机3.1 function 作用域默认值最安全也最“奢侈”不显式声明时fixture 的 scope 就是function每次测试函数或测试方法请求它都会重新执行一遍。用代码来验证import pytest count 0 pytest.fixture(scopefunction) def my_fixture(): global count count 1 print(f第 {count} 次创建 my_fixture) data {value: count} yield data print(my_fixture 销毁) def test_one(my_fixture): assert my_fixture[value] 0 def test_two(my_fixture): assert my_fixture[value] 0执行后会看到第 1 次创建 my_fixture my_fixture 销毁 第 2 次创建 my_fixture my_fixture 销毁两个测试函数各自触发了一次完整的创建和销毁。每个用例拿到的都是全新的 fixture 实例彼此没有任何状态共享。这带来的最大好处是隔离性最好最大坏处是开销最高。function 作用域最适合两类场景一是测试对被依赖的数据有强隔离要求比如每个用例都要一个独立的临时账号二是 fixture 本身执行成本很低重复执行可以忽略不计。如果不太确定该怎么选用默认的 function 是最稳妥的起点。实务里我见过不少同学一上来就追求“性能优化”把一堆 fixture 提到 module 甚至 session结果用例之间互相影响查问题查到怀疑人生。性能优化要建立在正确性和隔离性被满足的前提下。另一点值得一提的是当测试类是class类型时function 作用域对每个测试方法都生效而不是对整个类生效。也就是说一个测试类里有 3 个测试方法fixture 会创建 3 次。如果希望整个类的测试方法共享同一个实例就得用 class 作用域。3.2 class 作用域让同一个测试类的用例共享实例class 作用域的含义是fixture 在一个测试类内只创建一次该类下所有测试方法共享同一个实例不同类之间各自独立创建。import pytest pytest.fixture(scopeclass) def shared_data(): print(创建共享数据) data {users: []} yield data print(销毁共享数据) class TestUserFlow: def test_add_user(self, shared_data): shared_data[users].append(alice) assert len(shared_data[users]) 1 def test_user_count(self, shared_data): # 注意这里拿到的还是上一个测试方法改过的 shared_data assert len(shared_data[users]) 1 class TestOtherFlow: def test_data_is_fresh(self, shared_data): assert shared_data[users] []执行顺序大致是创建共享数据 销毁共享数据 创建共享数据 销毁共享数据两个测试类各自创建了一次自己的shared_data。TestUserFlow里两个测试方法拿到的是同一个列表所以第二个测试方法看到的用户数量是 1而TestOtherFlow创建的是全新实例所以数据是空的。class 作用域听起来很适合“同一个测试类共用一份资源”的场景但使用时有几个必须警惕的地方第一测试方法的执行顺序不该被依赖。如果你让test_add_user修改了共享数据然后test_user_count来断言修改后的结果这两个用例就产生了顺序耦合。单独跑test_user_count时根本不知道前面有没有执行过test_add_user用例就会随机失败。想解决要么把测试方法设计成幂等要么避免在测试方法之间通过共享状态传递数据。第二用 pytest 命令单独指定某个测试方法执行时class 作用域照样只创建一次。比如你只跑一个测试类里的一个测试方法这个 fixture 也会只创建一次不会因为只跑一个方法反而创建出一个多余实例。第三class 作用域不止适用于 unittest 风格的测试类也适用于普通 class 组织测试的场景。只要 pytest 收集到的测试项是以类方法形式存在的它就能正确识别并复用。3.3 module 作用域一个测试文件共用一个实例module 作用域把 fixture 的生命周期扩展到整个测试文件。一个 .py 文件从开始执行到结束fixture 只创建一次、销毁一次。文件里可能有多个测试函数和多个测试类它们都共享同一个实例。import pytest pytest.fixture(scopemodule) def db_session(): print(初始化数据库连接池) conn_pool create_pool() yield conn_pool print(关闭连接池) conn_pool.close() def test_query_1(db_session): assert db_session.is_connected() def test_query_2(db_session): assert db_session.is_connected() class TestDB: def test_query_3(self, db_session): assert db_session.is_connected()执行情况如下初始化数据库连接池 关闭连接池可以看到db_session在整个模块中只初始化了一次三个测试最终用的都是同一个连接池。这在数据库操作类测试里非常常见如果你每个测试函数都新建一个连接池那光建立连接的开销就能把测试拖慢好几倍但如果你把连接池提到 session 级又可能跨文件共享产生意想不到的连接状态问题。module 是个很好的折中同一批测试文件共享一个池不同文件之间互不影响。但 module 作用域也有一个典型盲区——如果同一个 fixture 定义在 conftest.py 里模块作用域也只对该模块生效。换句话说module 是“每个模块一份”不是“所有模块共一份”。假设你的测试目录下有三个测试文件都调用了db_session那这个 fixture 会分别创建三次每个文件独立持有自己的那一次实例。这一点对理解“模块内共享、模块间隔离”很关键。实操中我通常会把那些“创建一次后内部状态保持稳定、不需要跨模块共享、但每个模块都可能会用”的资源放 module 作用域比如日志收集器、测试数据文件路径、统一的日期时间戳等。这些资源本身不带复杂状态即使偶尔状态变化也只影响当前模块排查问题相对容易。3.4 session 作用域整个测试会话只跑一次性能最强风险也最大session 是 pytest fixture 生命周期里最长的一个作用域从 pytest 启动开始创建直到整个测试会话结束才销毁。所有测试文件、所有测试类、所有测试函数只要请求了同一个 session 级 fixture拿到的都会是同一个实例。import pytest pytest.fixture(scopesession) def global_config(): print(加载全局配置) config load_config_from_file(config.yaml) return config def test_config_in_file_a(global_config): assert env in global_config class TestSomething: def test_config_in_class(self, global_config): assert global_config[env] test执行时你会看到“加载全局配置”只出现一次。session 作用域的典型受益场景非常明确接口自动化里的统一登录态、token 管理、全局的 HTTP 连接池UI 自动化里的浏览器实例但要注意pytest 官方推荐用scopesession创建 WebDriver用 yield 做统一退出重量级测试数据准备比如往一个共享数据库里插入一大份基准数据全量测试只要一份读取配置文件、环境变量、远程测试环境的基础连接信息。但 session 级 fixture 是四兄弟里最需要敬畏的一个因为它的共享范围最大一旦状态被污染影响面是整个测试会话。举个例子为了节省登录次数你定义了一个 session 级tokenfixture登录成功后返回 token。结果有个测试用例不小心改了 token 的缓存值或者该用户被服务端踢下线了后续所有依赖 token 的用例全部失败而且失败原因千奇百怪排查起来非常崩溃。实际项目里我吃过一次更大的亏把数据库一键重置操作定义为 session 级 fixture里面会先清空所有表再初始化基础数据。测试套件并发执行时多个 worker 同时跑到这个 fixture结果一个 worker 刚插入的数据被另一个 worker 的清空动作直接抹掉现场一片混乱。后来排查了很久才意识到的不是每个重量级 fixture 都适合 session 级还要看它有没有副作用、能不能并发安全执行。用 session 级的时候至少要回答自己三个问题这个 fixture 创建出来的资源能否被整个测试会话安全共享如果变化或被改了影响范围是否能接受是否有并发执行的需求如果有fixture 内部逻辑是否线程安全、幂等teardown 时需要做什么session 级的销毁是最晚的清理动作如果失败错误信息会出现在最后面很不显眼容易被漏看。3.5 作用域销毁顺序的官方保证掌握四种作用域的销毁顺序很重要因为 fixture 清理时还可能依赖其他 fixture 的资源。pytest 官方建议的销毁顺序是先创建的后销毁也就是 LIFO后进先出策略。资源互相依赖时清理顺序一定是依赖方先清理被依赖方后清理。简单记忆fixture 的 teardown 是按作用域生命周期倒序执行的。session 级最晚销毁module 级次之class 级再次function 级最早。如果同一个测试函数依赖了多个 fixture销毁顺序和创建顺序相反。以下面这个例子为例pytest.fixture(scopesession) def session_fixture(): print(session_fixture 创建) yield print(session_fixture 销毁) pytest.fixture(scopemodule) def module_fixture(session_fixture): print(module_fixture 创建) yield print(module_fixture 销毁) pytest.fixture(scopefunction) def function_fixture(module_fixture): print(function_fixture 创建) yield print(function_fixture 销毁) def test_demo(function_fixture): print(测试执行)执行输出顺序为session_fixture 创建 module_fixture 创建 function_fixture 创建 测试执行 function_fixture 销毁 module_fixture 销毁 session_fixture 销毁依赖关系一目了然清理时严格反序。理解了这个顺序就不会写出“依赖资源已被提前清理”的低级 bug。4. 实战选型fixture 作用域到底怎么选这里给一套判断标准4.1 一张判断表直接抄作业与其背概念不如直接给一套判断流程。每拿到一个待编写的 fixture按下面顺序逐步问自己问题如果答案是“是”建议作用域核心理由fixture 执行成本极低且测试之间要求完全隔离function无状态共享风险也不会有性能压力fixture 有内部状态且多个测试方法需要协作操作同一份数据class限定在类内隔离面可控同一测试文件的测试函数都需要同一个资源且资源不可跨函数复用module文件内共享文件间隔离资源初始化成本很高且内容基本不变所有测试都只需要同一份session最大化复用减少重复初始化开销fixture 含有用户登录、鉴权、连接等全局性资源session一次登录全局使用节省大量时间fixture 有副作用或数据会被测试修改function 或 class避免状态污染扩大化这张表不能取代思考但对大部分常规场景足够用了。关键是把握一个原则作用域越大性能收益越高但隔离性越差作用域越小隔离性越强但重复执行的开销也越大。选型就是在这两者之间找平衡点。4.2 一个接口自动化项目的真实选型过程举个真实场景帮助理解。假设我在做一套电商系统的接口自动化测试测试范围包括用户登录、商品浏览、下单、支付、订单查询。我一般会这样组织 fixture# conftest.py import pytest import requests pytest.fixture(scopesession) def base_url(): 全局基础 URL整个会话只读一次 return https://api.testshop.example.com pytest.fixture(scopesession) def admin_token(base_url): 管理员登录态全会话只登录一次返回 token resp requests.post(f{base_url}/admin/login, json{username: admin, password: ***}) assert resp.status_code 200 token resp.json()[data][token] yield token print(管理员 token 会话结束无需主动注销服务端会自动过期) pytest.fixture(scopemodule) def order_module_data(base_url, admin_token): 商品模块共享的测试数据创建一个新商品商品 ID 供模块内多个用例使用 resp requests.post(f{base_url}/admin/products, headers{Authorization: fBearer {admin_token}}, json{name: 测试商品, price: 100}) product_id resp.json()[data][product_id] yield product_id requests.delete(f{base_url}/admin/products/{product_id}, headers{Authorization: fBearer {admin_token}}) pytest.fixture(scopefunction) def user_token(base_url): 每个用例独立的普通用户登录态防止用例之间共享账号状态互相干扰 username fuser_{uuid.uuid4().hex[:8]} requests.post(f{base_url}/user/register, json{username: username, password: 123456}) resp requests.post(f{base_url}/user/login, json{username: username, password: 123456}) yield resp.json()[data][token] # 测试结束后删除该临时用户 requests.delete(f{base_url}/user/{username}, headers{Authorization: fBearer {resp.json()[data][token]}})我来解释一下每个选择背后的考虑base_url用 session 是因为 URL 本身就是静态常量创建 100 次也一样完全没必要。admin_token用 session 是因为后台管理接口的登录通常比较慢而且一次全量回归可能涉及几十个后台操作接口如果每个用例都重新登录一次时间浪费会非常明显。同时管理员账号一般不做数据重建只读场景多共享风险可控。order_module_data用 module 是因为“创建一个商品供多个相关用例使用”是模块内部逻辑。如果放到 session 级商品创建后要被所有模块共享万一某个模块改了商品状态会影响别的模块。放在 module 级哪怕状态变了影响范围也只是当前文件。user_token用 function 是因为普通用户的创建和登录最频繁而且每个用例需要不同的用户数据来测试下单、支付等独立流程。一旦共享账号不同用例互相操作了对方的购物车或订单数据全是乱的。这套组合跑下来后台管理类用例整体执行时间能压缩到原来的三分之一左右而用户业务类用例也不会出现跨用例数据污染的困扰算是一个相对均衡的实践方案。4.3 别为了“快”去盲目提升作用域这里必须给一个反向提醒很多人听说了 session 级能省时间就把所有 fixture 都加上了scopesession结果就是测试套件跑起来越来越“神经质”偶发性失败率直线上升。原因非常简单——大作用域共享的资源只要被任何一个用例写脏了后续所有用例都会遭殃。我见过一个团队的项目里面对数据库连接的初始化用了 session 级同时某个测试模块里有一段清理数据表的 teardown。由于该模块的清理动作执行得比预期晚因为 module 级的 teardown 只在该模块所有测试结束后触发导致整个 session 内后续模块的查询全部失败。问题的根源不在于 teardown 写得不对而在于把“环境恢复”放进了 module 级却把“数据库连接”放在了 session 级二者的作用域不匹配清理动作覆盖范围小于共享资源的使用范围必然出问题。所以每次把作用域从 function 往上调一级之前都先问自己一句“为了省下这次创建我愿意承担多大的状态污染风险”如果答案不清晰请老老实实留在低一级。5. 参数化与 autouse作用域最容易踩到的两个隐蔽组合5.1 scope 与 parametrize 组合时的执行次数pytest.mark.parametrize参数化是 pytest 非常高频的功能但当参数化和 fixture 叠加时很多人对执行次数理解不了。先明确一个基础事实fixture 的作用域描述的是“同一组参数情况下”的缓存行为。一旦 fixture 本身被参数化那么不同参数组合的 fixture 实例是互相独立的。看个例子import pytest pytest.fixture(scopemodule, params[mysql, postgresql]) def database(request): print(f连接数据库 {request.param}) yield request.param def test_query_data(database): print(f查询 {database})这里database是 module 级但带了两个参数。pytest 会把测试函数展开成两种参数组合每个组合都会单独创建一个 module 级实例。也就是说这个模块会执行两次测试函数第一次用 mysql第二次用 postgresql每次都是“用完才销毁”。理解这个机制很重要尤其当你用 session 级 fixture 配合参数化时要注意 session 级的“一次”指的是“同一个参数组合的一次”。如果 fixture 有多个参数session 级也会执行多次——每个参数组合各一次。举例pytest.fixture(scopesession, params[chrome, firefox]) def browser(request): return request.param这个browser虽然标注了 session 级但在完整执行一套测试时会出现两次初始化一次 chrome一次 firefox。不是“整个测试会话只启动一个浏览器”而是“同一个浏览器参数在整个会话中只启动一次”。很多自动化框架在实现多浏览器兼容测试时被这个细节坑过以为 session 级浏览器只跑一次结果实际上每个浏览器参数都跑了一整轮用例。想控制全局只启动一次浏览器、让不同浏览器在内部自动切换那就不能用 pytest 的参数化机制得在 fixture 内部自行管理浏览器实例。5.2 autouse 的默认作用域是 function别把它当全局钩子autouseTrue意味着当前作用域内所有测试不需要显式声明参数pytest 也会自动请求该 fixture。很多人觉得 autouse fixture 就是“全局前置钩子”随手就写pytest.fixture(autouseTrue) def setup_teardown(): print(每个用例前执行) yield print(每个用例后执行)注意这个 fixture 的 scope 没写默认是 function所以它只对当前模块如果定义在 conftest.py 里则对所有测试文件的每个用例生效。autouse 并不改变作用域它只改变“是否自动注入”的规则。如果想在 session 开始时执行某段代码、session 结束时执行某段清理请明确加scopesessionpytest.fixture(scopesession, autouseTrue) def global_setup_teardown(): print(整个测试会话开始前执行) yield print(整个测试会话结束后执行)顺带一提session 级 autouse fixture 很适合用来做测试环境的预热、临时目录的创建、环境变量的设置等。但要注意如果 conftest.py 里放了 session 级 autouse fixture而测试用例又非常多它的 teardown 要等到所有模块都执行完才会触发。调试单个测试文件时虽然也会触发 session 级 autouse但作用范围仅限当前被调试的这个文件不会干扰其他文件。5.3 动手验证自定义一个计时器 fixture 来观察生命周期纸上谈兵永远不够。我建议你亲手做一个小实验验证四种作用域的实际行为import time import pytest pytest.fixture(scopesession) def session_timer(): print(session_timer 启动) start time.time() yield print(fsession_timer 结束, 总耗时 {time.time() - start:.2f}s) pytest.fixture(scopemodule) def module_timer(): print(module_timer 启动) yield time.time() print(module_timer 结束) pytest.fixture(scopeclass) def class_timer(): print(class_timer 启动) yield time.time() print(class_timer 结束) pytest.fixture(scopefunction) def function_timer(): print(function_timer 启动) yield time.time() print(function_timer 结束) def test_one(session_timer, module_timer, function_timer): pass def test_two(session_timer, module_timer, function_timer): pass class TestClassA: def test_a1(self, class_timer): pass def test_a2(self, class_timer): pass这段代码虽然简单但执行后你能亲眼看到session_timer只在最开始打印一次启动、最后打印一次结束module_timer虽然被两个测试函数引用了但只创建一次function_timer每次都创建class_timer在整个TestClassA中只创建一次。自己动手跑一遍比看十遍文档都记得牢。这种实验也可以当作面试题去考团队里的新人看他们能不能准确说清楚输出顺序。6. 高级技巧session 级 fixture 如何实现“全局只登录一次”6.1 接口自动化里 token 管理的常见做法接口自动化的登录态管理是最典型的 session 级 fixture 场景。如果每个接口用例都重新登录一次一个 500 个接口用例的回归套件光是登录就耗时很久而且有的服务端登录接口会限制单位时间内的调用次数频繁登录容易把账号锁死。解决方案就一个把登录请求封装成 session 级 fixture整个 pytest 进程周期内只登录一次后续所有用例共享拿到的 token。baseline 大家可以参考下面这个实现# conftest.py import pytest import requests from jsonpath import jsonpath class ApiClient: def __init__(self, base_url, tokenNone): self.base_url base_url self.session requests.Session() if token: self.session.headers[Authorization] fBearer {token} def get(self, path, **kwargs): return self.session.get(f{self.base_url}{path}, **kwargs) def post(self, path, **kwargs): return self.session.post(f{self.base_url}{path}, **kwargs) pytest.fixture(scopesession) def api_client(base_url, account_info): 全局唯一的 API 客户端自动携带登录态 client ApiClient(base_urlbase_url) # 登录一次拿 token resp client.post(/auth/login, jsonaccount_info) assert resp.status_code 200, f登录失败: {resp.text} token jsonpath(resp.json(), $.data.token)[0] client.session.headers[Authorization] fBearer {token} yield client print(测试会话结束ApiClient 不再使用)这个实现好在哪它将所有接口调用的基础操作封装到ApiClient中把登录和携带 token 的过程集中放进了 fixture。所有测试用例只需要接收api_client一个参数不需要关心 token 怎么来的、当前是否过期。之后如果服务端登录接口有调整只需要改这一处 fixture。session 级 fixture 的缓存释放也需要注意如果 token 中途失效需要新增“重新登录”逻辑。典型的做法是给ApiClient增加一个ensure_authorized方法在每次请求前检查响应码当服务端返回 401 时自动重新登录并重放请求。这个机制是 session 级资源规避“过期失效”最常见的补强手段。6.2 UI 自动化里浏览器实例的 session 级管理UI 自动化基于 Selenium 或 Playwright对浏览器实例的管理同样常用 session 级。每个测试用例如果都重新启动一个浏览器启动耗时 2 到 5 秒不说还很容易因为窗口句柄、浏览器缓存等问题产生不稳定。把浏览器实例加载到 session 级 fixture 里是所有用例共用一个浏览器窗口可以显著提速。一个朴素的 Selenium 示例import pytest from selenium import webdriver pytest.fixture(scopesession) def driver(): options webdriver.ChromeOptions() options.add_argument(--headlessnew) options.add_argument(--no-sandbox) driver webdriver.Chrome(optionsoptions) driver.maximize_window() yield driver driver.quit()但是共用浏览器窗口带来的新问题是——用例之间如果不清理页面状态上一个用例的 cookie、localStorage、URL 残留会直接污染下一个用例。很多人的解决方式是在每个用例结束后调用driver.delete_all_cookies()或者driver.execute_script(window.localStorage.clear();)但这又属于 function 级的清理范畴不能写在 session 级 fixture 的 teardown 里否则等于一次会话只清理一次基本没用。所以 UI 自动化更合理的组合是session 级创建浏览器实例省掉启动耗时function 级每个用例前清理 cookie 和本地存储并打开一个干净的起始页function 级每个用例结束后的失败截图和日志收集。把“启动浏览器”和“清理页面状态”区分开来各自放在合适的作用域里才能既快又稳。这个案例也印证了我在 4.1 讲过的核心方法论——不要试图用一个 fixture 解决所有生命周期问题。6.3 在 fixture 中动态切换作用域scope 参数必须静态声明有个容易让人误解的小知识点提前说明白scope必须是一个静态的字符串常量不能根据运行状态动态返回。比如下面这种写法是错的pytest.fixture(scopesession if os.getenv(RUN_MODE) prod else function) def dynamic_fixture(): pass这种做法并不能按环境动态决定作用域。pytest 在收集阶段会预先解析所有 fixture 的元数据动态计算出的 scope 不会得到预期效果。官方文档里提到的request.scope是用来在 fixture 内部获取当前运行时的作用域名称的不是用来“修改”自己的。想按环境决定作用域一个可行的办法是自己封装一个小函数def _choose_scope(): return session if os.getenv(RUN_MODE) prod else function pytest.fixture(scope_choose_scope()) def some_fixture(): pass因为_choose_scope()在模块导入期间就已经执行完毕得到的字符串在收集阶段是静态确定的所以这种方式是可行的。7. 常见报错与排查技巧作用域相关的疑难杂症速查7.1 fixture teardown 顺序与 scope 不匹配的报错如果你在测试日志里看到了类似这样的报错ERROR: fixture teardown problem那大概率是某个 fixture 的 teardown 里访问了已经被销毁的资源。以我的经验最容易出事的是在 session 级 fixture 的 teardown 里调用了 function 级或其他低级fixture 提供的资源。比如pytest.fixture(scopefunction) def user_id(): return create_user() pytest.fixture(scopesession, autouseTrue) def cleanup(user_id): yield delete_user(user_id)这段代码想表达“每个用例创建用户session 结束后清理所有用户”但因为cleanup是 session 级user_id是 function 级pytest 在收集阶段就会陷入“session 级 fixture 依赖 function 级 fixture”的矛盾中——低作用域资源根本活不到 session 结束。面对这种情况要么把user_id也提为 session 级要么先创建一个 scopesession 的存储容器把每个用例创建的用户 ID 记录进去在 session teardown 统一清理。第二种做法更合理因为每个用例确实需要独立用户而清理动作确实应该全局统一执行。7.2 fixture 被重复执行scope 没生效的排查清单有的人加了scopesession却仍然发现 fixture 执行了很多次。排查思路按顺序走确认 scope 值有没有真的生效。scope session不等于scope Session拼写错误会导致 pytest 在收集阶段直接报未知 scope 的错误但如果传的是合法但非预期的值它也会执行但你观察到的行为就会和预期不一致。确认 fixture 是不是定义在 conftest.py 里。同一个 fixture 名字定义在测试文件内和定义在 conftest.py 里作用域含义完全一样但可见范围不同。如果你在两个文件里各定义了一个同名但作用域不同的 fixture就可能导致某些文件里看似 session 级、另一些文件里却是 module 级的情况。确认你是否用了参数化。参数化会为每个参数组合独立创建 fixture如果参数组合有 10 个即使标注 session 级也会执行 10 次。确认 fixture 是否被多个“会话”执行。如果你在跑 pytest-xdist 并发默认每个 worker 都是一个独立的 pytest 进程session 级 fixture 会在每个 worker 中各跑一次。想让并发时也保证全局唯一需要结合插件如pytest-xdist的--dist loadgroup或者自定义文件锁来做这也是很多接口自动化团队从单进程切换到并发后才遇到的坑。7.3 最容易被忽略的“fixture 返回值被测试代码修改”问题最后分享一个真实踩坑。某次自动化回归里我定义了一个 module 级 fixture返回一个列表作为测试基准数据pytest.fixture(scopemodule) def base_products(): return [ {name: 商品A, stock: 10}, {name: 商品B, stock: 20}, ] def test_a(base_products): base_products.append({name: 商品C, stock: 30}) assert len(base_products) 3 def test_b(base_products): assert len(base_products) 2 # 断言失败test_b失败的原因是test_a直接修改了 fixture 返回的原始列表。因为两个测试函数共享同一个列表对象第一个改了第二个拿到的就是改过的。function 级 fixture 不会出现这种问题因为每次都会新建一个列表module 级 share 了实例之后用例之间共享数据产生副作用的概率大增。解决方案有几种在返回数据前用copy.deepcopy做一次深拷贝让每个请求方拿到独立副本明确约定 fixture 返回的数据对象是只读的不能修改如果数据本身会被业务逻辑修改降低作用域或者让 fixture 返回一个能生成新副本的工厂函数。我个人的习惯是被多个用例共享但可能被修改的数据对象一律用工厂函数模式返回新副本而不是直接返回同一份共享实例。这样既能保留 module 级带来的初始化性能收益又能规避共享数据被意外修改的风险。pytest.fixture(scopemodule) def products_factory(): # 内部只执行一次初始化 base_data [ {name: 商品A, stock: 10}, {name: 商品B, stock: 20}, ] def _factory(): return copy.deepcopy(base_data) return _factory def test_a(products_factory): data products_factory() data.append({name: 商品C, stock: 30}) assert len(data) 3 def test_b(products_factory): data products_factory() assert len(data) 2 # 通过7.4 问题排查速查表症状可能原因解决方向fixture 执行次数远超预期scope 未按预期生效、参数化导致多实例检查字符串拼写、检查参数化组合数不同测试文件间共享数据污染把变化的数据放进了 session 级降低作用域或使用独立副本module 级 fixture 在该模块结束后才清理这是正常行为调整对清理时机的预期跨文件用例互相影响在 conftest.py 定义了大作用域但数据未隔离使用工厂函数或降低 scopesession 级 fixture 在并发时执行多次pytest-xdist 每个 worker 独立全局唯一资源需要考虑跨进程方案teardown 里访问已销毁对象作用域层级不匹配调整 fixture 层级或引入容器统一管理这张表是我这几年来遇到的高频问题的浓缩建议收藏下来等自己项目里遇到相似现象时对照排查能省不少时间。8. 作用域与自动化测试架构的延伸思考关于 fixture 作用域还有最后一层值得展开的思考。我之前断言“作用域是 fixture 生命周期策略”但它往深层讲其实反映了我们对测试套件架构边界的理解。一个依赖 session 级共享资源的测试套件本质上是一个耦合的整体。它对测试执行顺序、环境稳定性、并发策略都非常敏感。哪怕是pytest-randomly这样打乱测试顺序的插件都可能因为 session 级资源在某个用例中被污染而引发大量连锁失败。反过来如果绝大部分 fixture 都是 function 级测试套件的独立性会非常好但执行效率可能难以接受。成熟的自动化测试架构往往把 fixture 的作用域当作一种“共享策略”来设计全局只读的环境配置、常量、外部服务地址用 session 级模块内共享的流程局部状态用 module 级类内共享的协作性数据用 class 级每个用例独立的数据、临时对象、需要保证隔离的资源用 function 级。很多时候把测试用例的“数据准备”和“数据清理”拆开来看会比把“整个业务动作”当作一个 fixture 来设计更合理。业务动作的依赖关系复杂、状态易变不适合做高作用域而数据准备往往是初始化后不再变化适合做高作用域。比如跑接口自动化时“创建一批测试商品”可以做成 module 或 session 级但“模拟用户下单”最好还是 function 级因为每次下单都会产生新订单号。这种拆分的思维模式远比记住 scope 参数有哪几个值更重要。你用 pytest 越久越会体会到真正让测试套件解耦的不是精巧的 fixture 封装而是清晰的资源边界。9. 几个能直接抄进项目的小技巧最后再送几个可以直接用的实用技巧这些都不是文档里能轻易看到的全是我实际项目中反复用到的。第一个是在 conftest.py 中打印当前 scope 帮助调试。如果你对某个 fixture 到底执行了几次有疑问直接在 fixture 内部打一行日志再在conftest.py里配置好 log 输出跑一次就能看到完整的创建销毁时间线。第二个是给 session 级 fixture 加全局超时保护。session 级资源一旦创建失败或卡住pytest 默认会一直等待直到整个会话结束才报告异常反馈非常慢。我通常在 session 级 fixture 内部使用带超时时间的原语比如用requests的timeout参数或者直接给整个 fixture 包一层threading.Timer确保初始化卡死时能在预期时间内快速失败。第三个是用request.scope做兼容性判断。有的 fixture 可能在不同场景下被复用写的时候先打印出request.scope或者在特定作用域下采取不同逻辑。这个属性也可以用于调试排查看当前实例到底处于什么生命周期。第四个是注意别在 session 级 fixture 里使用不可序列化的对象比如文件句柄、套接字因为你不知道后续会不会为了并发改造引入 pytest-xdist。跨进程传递资源时不可序列化对象会成为改造的最大阻碍。如果一开始把配置类资源设计成“轻量可复制、重量可重建”将来扩展并发能力时会轻松很多。实测下来把上面几个习惯融入日常开发能明显减少后期维护自动化测试套件时的“惊吓”次数。我个人在实际项目中最大的感受是pytest fixture 的作用域本身并不难理解难的是在每个具体 fixture 面前能克制住“想省时间就不管不顾提 scope”的冲动。隔离性和性能之间的权衡是一门需要不断实践才能掌握的学问。希望你现在再遇到“Function、Class、Module、Session 怎么选”这个问题时脑子里浮现的不再是死记硬背的概念而是一套清晰的判断逻辑。