资讯详情

从“屎山”到工业级:pytest conftest.py 重构实战

📅 2026/9/29 10:57:10 | 华诺云谱 👁 阅读
从“屎山”到工业级:pytest conftest.py 重构实战
1. 重构的起点先看现状再动手前阵子接手了一个维护了两年的测试项目第一次打开根目录的conftest.py时我盯着屏幕沉默了半分钟。文件一共 1800 多行里面混着 fixture、钩子函数、环境变量读取、数据库初始化、登录逻辑、数据清理脚本、还有三处被注释掉的旧实现。更让人头疼的是这个文件被十几个测试模块隐式依赖一旦改动某个 fixture 的返回结构测试套件就会像多米诺骨牌一样倒下而且倒得毫无规律。这不是个别现象。很多测试团队在项目初期图省事把所有共享逻辑都塞进conftest.py等用例数量从几十涨到几千这个文件就变成了一座“屎山”。企业级conftest.py的重构本质上是把一把乱线整理成一卷可以随时抽拉的线轴——既要保证每个 fixture 职责清晰、可见性合理又要让新增用例时不用反复改动公共文件。我在动手之前先做了一件事统计这个文件里所有 fixture 被哪些测试模块引用了。用脚本扫描一遍之后发现真正被超过三个模块共用的 fixture 只有 7 个其余三十多个 fixture 只有一两个模块在用其中有 5 个根本没有任何测试引用纯属历史遗留。这个数据非常重要——它决定了哪些 vulture 可以安心清理哪些需要重新安置哪些必须保留在原位。重构的目的是把一个不可维护的巨型文件拆解成可维护的测试基础设施而不是单纯地“减少代码行数”。我给自己定了三条底线行为不变量优先、可见性收敛、渐进式迁移。也就是说重构过程中测试用例的代码尽量少改fixture 对外暴露的名称和返回结构尽量保持稳定每一步改动之后都要跑一次全量测试来确认没有破坏任何行为。如果你也想重构项目里的conftest.py第一步不是打开文件开始删代码而是先回答三个问题这个文件里哪些东西是被测试代码直接依赖的哪些是间接依赖的哪些是根本不依赖的只有把依赖关系摸清楚后续的拆分才有依据。2. 拆解巨型文件分层设计与模块划分2.1 从“一个文件管所有”到“一层管一件事”conftest.py之所以容易膨胀是因为 pytest 的设计赋予了它一种“隐式能力”——同一目录下的测试模块会自动继承这个文件里定义的 fixture 和钩子。这个能力在小型项目里很有用但在企业级项目里它恰恰成了代码混乱的温床。我的拆分原则基于目录层级和职责边界而不是把所有东西平铺在一起。重构之后测试基础设施被分成四层tests/ ├── conftest.py # 全局兜底所有测试模块共享的极少数核心fixture ├── fixtures/ │ ├── __init__.py │ ├── auth.py # 登录态、Token相关 │ ├── data.py # 测试数据准备、清理 │ ├── http.py # 请求客户端、响应校验 │ └── env.py # 环境变量、配置读取 ├── plugins/ │ ├── __init__.py │ ├── pytest_opt.py # 命令行参数注册 │ └── pytest_metrics.py # 测试执行数据采集 ├── modules/ │ ├── order/ │ │ ├── conftest.py # 订单模块专属fixture │ │ └── test_order.py │ └── payment/ │ ├── conftest.py │ └── test_payment.py这里有一个关键设计fixtures/目录里的模块只是“fixture 的存放处”它们本身不是conftest.py不会被 pytest 自动发现。要让这些 fixture 生效得在根目录的conftest.py里显式导入并重新声明。这一步看起来有点绕但它的好处在于每一个 fixture 的来源一目了然不会出现“这个 fixture 到底定义在哪个文件里”的搜索困境。至于模块级的conftest.py它在 pytest 的 fixture 查找顺序里优先级更高——当tests/modules/order/test_order.py请求一个 fixture 时pytest 会先找同级目录下的conftest.py找不到再往上层查。利用这个机制我把订单模块专用的 mock 数据 fixture 从根文件里挪到了tests/modules/order/conftest.py测试代码本身一行没改但根conftest.py一下子瘦身了 300 多行。2.2 fixture 可见性与命名治理在拆文件之前我先梳理了所有 fixture 的可见性范围。pytest 的 fixture 本质上是一个依赖查找链测试函数请求某个 fixture 时pytest 会从当前的测试模块目录逐级向上寻找定义。这个机制决定了你无法阻止某个模块“意外用上”一个不该它用的 fixture但你可以通过目录层次来约束它。重构中我把 fixture 分成了三类全局共享型例如数据库连接、CI 环境标识读取、日志收集器这些被所有模块依赖留在根conftest.py。模块共享型例如订单模块的测试数据工厂、支付模块的 mock 网关客户端下沉到各自的模块conftest.py。单一用例型只在某个测试类里用到的辅助 fixture直接搬进测试文件内部定义不再放进conftest.py。命名上也做了统一。以前有get_token、gen_token、fetch_token_2019这种命名混用的情况我全部规范成“名词性短语”风格auth_token、order_factory、db_session。这么做的原因是fixture 是一个可注入的对象它不是一次性的函数调用名词性命名更贴合它的本质。你在写测试时看到def test_can_cancel_order(auth_token, order_factory):不用看实现就知道传入的是什么。2.3 钩子函数与 fixture 分离conftest.py里另一类容易和 fixture 混在一起的东西是 pytest 钩子函数pytest_addoption、pytest_configure、pytest_collection_modifyitems等。我见过不少项目把它们和 fixture 堆在一起写看起来像是同一个主题实际上职责完全不同——fixture 是“测试运行时的依赖注入”钩子函数是“pytest 框架生命周期的干预点”。我的做法是把所有钩子函数迁移到plugins/目录做成独立的 pytest 插件模块。同样的效果但不同的组织方式。举个例子原来的conftest.py里有一段代码根据命令行参数选择测试环境def pytest_addoption(parser): parser.addoption(--env, actionstore, defaulttest, helprun tests in specified env) def pytest_configure(config): env config.getoption(--env) os.environ.setdefault(TEST_ENV, env)这段逻辑本身没有问题问题在于如果未来还需要--parallel、--retry-times这类参数这个函数会越来越长。重构后我把它放到了plugins/pytest_opt.py里职责单一并且在pytest.ini中通过pytest_plugins选项显式声明加载不再依赖conftest.py的隐式发现。注意插件模块如果命名为pytest_opt.py这类风格pytest 有可能会自动加载它。为了避免这种隐式行为造成的二次混乱我在plugins/__init__.py里设置了__all__并且在pytest.ini里只声明具体的模块路径。显式永远比隐式更可控。3. fixture 治理从“能用”到“好用”3.1 作用域设计session、module 还是 functionfixture 治理是重构conftest.py的第二大战场。很多项目里的 fixture 使用错误的作用域最常见的就是所有 fixture 都默认scopefunction。这在用例少的时候感觉不到问题一旦用例增加到几千条每一条用例都要重新连接数据库、重建目录、重新登录测试时长会呈指数级增长。我重构时对每个 fixture 做了作用域分级session级数据库连接池、全局配置、http 客户端实例。这些对象创建成本高且线程安全整个测试会话只需要创建一次。module级模块内共享的测试数据集合、外部服务 mock 实例。一个测试模块共用一份数据即可。class级测试类内共享的临时目录、缓存对象。function级一切需要隔离状态的操作比如数据清理、token 刷新、文件状态修改。这里有一个容易被忽略的点scopesession的 fixture 如果内部依赖了scopefunction的东西就会触发 pytest 的“隐式提升”——低作用域 fixture 会自动升格为高作用域但这个升格并不总是安全的。重构中我发现一个老代码拿到了 session 级的db_conn但内部有一个 function 级的current_user依赖测试运行时数据状态经常串。解决方式是把current_user改为参数化输入而不是作为 fixture 依赖。另外yield fixture是处理资源清理的正确姿势。重构前我看到很多 fixture 里有“用完手动调用cleanup()”的逻辑测试代码一多就容易漏。重构后统一改成yield模式setup 阶段放在yield之前teardown 放在yield之后——不管测试是成功还是失败yield后面的代码都会执行这个机制能极大减少资源泄漏。3.2 工厂模式 vs. 直接返回对象这是我在重构中发现的一个有趣细节很多团队编写的 fixture 直接返回一个已经构建好的对象比如一个登录后的Session。问题在于不同用例可能需要不同的用户名、不同的权限角色、不同的租户 ID如果 fixture 把参数写死用例就不得不“绕过 fixture”自己构建数据反而绕远路。更合理的做法是把 fixture 写成工厂模式——它不返回一个具体的对象而是返回一个“能创建对象”的函数。举一个我重构后的示例pytest.fixture def auth_token_factory(http_client): created_tokens [] def _create_auth_token(user_roleadmin, tenant_idNone, expires_in3600): payload { role: user_role, tenant_id: tenant_id or faker.uuid4(), exp: int(time.time()) expires_in, } token http_client.post(/auth/token, jsonpayload).json()[token] created_tokens.append(token) return token yield _create_auth_token for token in created_tokens: http_client.delete(f/auth/token/{token})测试代码从原来的def test_x(auth_token)变成def test_x(auth_token_factory): token auth_token_factory(viewer)。表面上看改动多了一行但灵活度完全不同——每个用例都能创建自己想要的 token并且跟踪清理。这个模式在处理复杂数据结构时尤其好用比如订单工厂、用户工厂、支付流水工厂。工厂模式还有个隐藏的好处它天然规避了 fixture 作用域冲突。如果直接返回一个admin用户的 token并被声明为scopesession那么一旦有用例请求了viewer角色的 token就会产生冲突。工厂模式不存在这个问题每次调用按需创建、按需清理。3.3 配置管理环境信息与密钥不要写死在 fixture 里企业级项目一定会遇到多环境测试问题。重构前的conftest.py里有一堆硬编码的环境地址https://test-api.internal.example而且散落在七八个 fixture 里。每次切换环境就要全局替换非常容易漏改。重构后我在conftest.py中保留了一个统一的配置加载入口pytest.fixture(scopesession) def env_config(pytestconfig): env_name pytestconfig.getoption(--env, defaulttest) return load_env_config(env_name)这里的关键点有三个第一环境名通过命令行参数传递而不是通过修改代码切换第二密钥信息和敏感配置一律从环境变量或本地配置文件中读取不进入代码仓库第三env_config是一个 session 级 fixture整个测试周期只加载一次避免每个用例都重复读文件和解析环境变量。经验之谈如果项目里已经存在settings.py或config.py不要把它的实例直接放进 fixture。fixture 最好提供一个“应用层适配器”因为测试环境通常需要覆盖一些生产配置比如把第三方支付接口指向 mock如果直接引用生产配置对象覆盖起来会很麻烦。4. 插件化改造把钩子函数从 conftest.py 里请出去4.1 钩子函数的价值与滥用pytest 的钩子函数系统非常强大但也很容易被滥用。我见过最夸张的一个conftest.py里塞了 15 个pytest_runtest_*钩子其中一半实现的功能其实是“记录日志”和“打印进度条”。这些功能本身没问题但它们和测试数据、fixture 混在一个文件里每次想调整日志格式都得在巨型文件里翻找。插件化的核心思路是把“测试基础设施”拆成两部分业务相关的留在conftest.py体系内框架层面的做成独立插件模块。这里说一个我重构时写的自定义标记处理器用于给用例打标签然后根据标签决定是否执行# plugins/pytest_tags.py import pytest def pytest_configure(config): config.addinivalue_line(markers, slow: mark test as slow-running) def pytest_collection_modifyitems(config, items): if not config.getoption(--run-slow): skip_marker pytest.mark.skip(reasonneed --run-slow option to run) items[:] [ item.add_marker(skip_marker) if slow in item.keywords else item for item in items ]这段代码放不进conftest.py吗能放。但放了之后每当你想要给 pytest 增加一个新的--run-*参数就得回到conftest.py里往上加。插件化的模式则让人能够单独测试插件、单独维护插件还能很容易地给不同项目复用同一个插件——我手上有好几个项目共用的pytest_tags.py每个项目的conftest.py里只需要一行pytest_plugins [plugins.pytest_tags]就能生效。4.2pytest_plugins的显式声明戏法在conftest.py中通过pytest_plugins变量声明插件模块时需要留意一个细节这个变量的值必须是字符串或字符串列表且不能使用相对路径的模块名做隐式导入。举例# tests/conftest.py pytest_plugins [ plugins.pytest_opt, plugins.pytest_tags, ]如果你已经在这个conftest.py里定义了 fixture 和钩子同时又要加载插件这个写法是允许的但注意不要让pytest_plugins变量与使用pytest11入口点注册的插件重名否则会出现“模块被加载两次”的警告而且这类问题非常隐蔽。我个人建议conftest.py里保留的钩子函数越少越好向项目组成员约定“新增钩子必须走插件目录”这样conftest.py就变成一个“装载清单”而不是“实现仓库”。从我的实际经验看这个约定比代码审查还管用因为后者依赖人的记忆前者是架构上的硬约束。5. 性能优化重构后的 conftest 能省多少时间5.1 fixture 缓存命中率与测试时长重构之前我们的全量测试大概是 28 分钟重构后降到 19 分钟。这个提升主要来自两个地方第一是 session 级 fixture 的复用——以前每条用例都创建新的数据库连接重构后整个测试会话只建一次连接池第二是参数化与工厂化之后重复的初始化数据构建变少了。这里我记录了一个典型的耗时优化原先db_session是 function 级 fixture每条用例执行前都要跑一遍数据库迁移和种子数据初始化。一个模块 30 条用例就要 30 次初始化每次 4 秒光初始化就 120 秒。重构后我把db_session提升为 session 级但在内部使用“事务回滚机制”而不是每次重建数据——每个用例在同一个事务里执行用例结束后直接回滚数据状态回到起点。pytest.fixture(scopesession) def db_engine(env_config): engine create_engine(env_config.database_url, pool_size20) yield engine engine.dispose() pytest.fixture def db_session(db_engine): connection db_engine.connect() transaction connection.begin() session Session(bindconnection) yield session session.close() transaction.rollback() # 关键回滚而不是重建 connection.close()这种模式的正确性依赖一个前提所有测试用例都通过同一个db_session操作数据库不能在用例内部另起连接。如果你的测试代码里有绕过 fixture 直接Session()的情况这种优化会隐藏脏数据问题需要提前修正。5.2 减少重复收集conftest.py层级对 collection 的影响pytest 在收集测试用例时每个测试目录层级都会向上查找conftest.py并加载。如果你有几百个目录每个目录都有一份自己的conftest.py哪怕只是为了定义一个 fixturecollection 阶段就会反复加载这些模块。测试数量多的时候这个开销会变得不可忽略。重构时我把“目录级conftest.py”收敛到合理的数量只有真正需要“模块内特化 fixture”的目录才保留conftest.py其余目录统一使用根级共享 fixture。这不仅能减少 collection 时间还能避免一种常见的坑多人协作时在不同层级定义了同名 fixture导致 pytest 优先使用最近的一层而其行为与同事预期不一致排查起来非常浪费生命。5.3 动态生成测试与 fixture 缓存的平衡有些企业级项目会使用pytest_generate_tests钩子来动态生成基于数据的测试用例。例如针对接口文档里的几十个契约每个契约生成一个测试用例。这个机制非常灵活但它有一个副作用它让 fixture 的缓存策略变得更加复杂——因为 pytest 的 fixture 缓存是“按参数化 id 分别缓存”的如果参数组合太多session 级 fixture 实际上会被实例化多次。遇到这种情况我的做法是区分“真正的 session 级共享资源”和“逻辑上的 session 级共享数据”。前者比如数据库引擎、配置加载器可以放心复用后者比如某个 api 的返回结果如果它可能因请求参数不同而变化就不要盲目声明 session 级否则性能提升了测试的隔离性反而下降了。在我的重构清单里这类 fixture 从 session 级降为了 module 级用lru_cache或自定义缓存字典来显式控制复用粒度换取准确性和可维护性。6. 常见问题与排查技巧实录6.1 fixture 不可见“找不到名为 xxx 的 fixture”这是重构期间最容易出现的问题尤其是当你把 fixture 从根conftest.py挪到子目录或独立文件后。pytest 的 fixture 查找是基于目录层次的如果 fixture 定义在一个非conftest.py文件里且没有通过pytest_plugins声明测试运行时就会报fixture not found。排查思路就三步确认 fixture 定义文件是否被 pytest 加载可以在 pytest 命令加上--co来展示所有收集到的 fixture确认加载顺序是否在测试运行之前确认是否存在同名 fixture “覆盖”了你的定义。我最常踩的坑是第三种一个模块级别的conftest.py里定义了同名的简化版 fixture它和根conftest.py里的原版行为不一致测试结果就会非常难排查。6.2 fixture 循环依赖dependency cycle detected这个报错出现的场景是 fixture A 依赖 BB 又依赖 A。重构之前代码缩在一起别人一眼很难发现这种循环拆开之后循环依赖往往会在加载时暴露。解决方式不是硬拆而是找到循环里“真正的资源”。比如 A 依赖 B但 B 其实只需要 A 里的db_session而不是整个 A fixture。一种非常实用的技巧如果循环依赖的对象包含同一个连接或同一个工厂函数可以考虑把“底层共享资源”抽成独立的存储字段通过模块级单例来承载。虽然这有点像“破坏 fixture 体系”但实践上它远比疯狂重构来的高效。我这里说的不是在conftest.py里随意写全局变量——而是指那些从设计上就应该全局唯一的对象比如thread_pool、redis_client实例。6.3 重构后测试变慢排查隐形重复初始化有一次重构后全量测试变慢了 4 分钟排查很久才发现某个scopesession的 fixture 内部调用了另一个scopefunction的 fixture结果 pytest 把所有请求了前者 fixture 的用例都变成了“局部 session 级”相当于每条用例都在重复执行 session 级初始化。这个问题的诊断技巧是给 pytest 加上--setup-show它会把每个 fixture 的 setup 时机打出来一眼就能看到哪些 fixture 被反复创建。修复方式就是前面 3.1 节提到的一旦发现一个 session 级 fixture 实际依赖 function 级数据就应该考虑把依赖参数化而不是让低层级 fixture 自动升级。如果实在不能抽象至少要在注释里显式说明“这个 fixture 的时间复杂度是 O(用例数 × 初始化成本)”让后来的人知道这是有意为之。6.4autousefixture 滥用重构前项目里有 12 个autouseTrue的 fixture——这意味着每条用例无论需不需要都会执行这 12 个 fixture 的初始化。其中大部分只是“啊我可能需要环境变量”或“可能有数据库先连上再说”。重构后我只保留了 2 个autousefixture一个是日志上下文采集器一个是环境变量注入器。其余全部改成显式请求。这一步对测试时长的贡献很可观因为一个耗时 200ms 的 fixture 每天被 2000 条用例执行就是 4000 秒的额外负担而且绝大部分是浪费的。判断方法是逐项检查这个 fixture 真的需要被每一条用例执行吗它能不能被一个更细粒度的 fixture 替代它在测试报告中展示的信息是否值得消耗的时间成本如果答案是“不确定”那就先改成显式请求跑一轮测试观察失败率再决定去留。7. 后续还能怎么扩展重构完conftest.py之后我顺手做了一件回头看来很值得的事把根conftest.py的代码结构分成五个区块——插件声明、全局配置、全局 fixture 注册项、环境变量入口、autouse 最小集。每个区块之间用清晰的注释分隔并且在文件头部写了简短的“使用约定”说明标明新增 fixture 的放置规则、命名规范和可见性判断流程。一个工业级的conftest.py不是靠某一次重构一蹴而就的它需要在使用中持续维护。我自己的经验是每两个月安排一次小清理检查哪些 fixture 已经没人使用、哪些作用域设置不合理、哪些重复代码可以聚合。pytest 里有一个--fixtures -v选项能列出所有已收集 fixture 的可见性和作用域用它可以快速定位可疑的 fixture。如果你也想做类似的整理建议分三步走先把文件里的 fixture 全部列出来做依赖分析确定真实的共享边界其次按照“全局、模块、局部”三层安置 fixture尽可能让根conftest.py保持简约最后用显式的pytest_plugins管理钩子函数和插件让每个功能都有清晰的归属。这个思路看起来平淡但坚持下去你的测试代码质量会有一个肉眼可见的跃迁。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑