
1. 项目概述如果你在用pytest做自动化测试尤其是项目规模稍微大一点或者对测试报告、用例执行顺序有特殊要求时你大概率会碰到一个绕不开的“神器”——pytest_collection_modifyitems钩子函数。我第一次深入使用它是因为一个很实际的问题团队里不同的人写的测试用例执行顺序完全是随机的导致一些前置依赖的用例比如先登录、再创建数据、最后查询经常失败调试起来非常头疼。另一个场景是我们想把所有失败的用例集中起来再跑一遍手动筛选太麻烦。这时候pytest_collection_modifyitems就成了解决问题的关键。简单来说这个钩子函数是pytest在完成所有测试用例收集之后、真正开始执行之前留给我们的一扇“后门”。通过这扇门我们可以拿到本次测试运行将要执行的所有用例列表items然后随心所欲地对它们进行“改造”重新排序、过滤掉一些、给它们打上标记、甚至动态修改用例的属性。它不像pytest_addoption那样去定义命令行参数也不像pytest_runtest_setup那样干预单个用例的执行过程它的舞台在“集体”层面作用于整个用例集。理解并掌握它意味着你从pytest的“使用者”进阶为“定制者”能根据自己项目的独特需求灵活驾驭测试流程。2. 钩子函数的核心机制与定位2.1 pytest的钩子函数体系要理解pytest_collection_modifyitems得先把它放在pytest庞大的钩子函数体系里看。pytest的插件系统和高度可定制性很大程度上就建立在钩子函数Hook Function之上。你可以把pytest的测试执行生命周期想象成一条流水线从读取配置、发现用例、收集用例、执行用例到生成报告每个关键环节都预留了“挂钩点”。插件或者项目根目录下的conftest.py文件可以往这些挂钩点上挂载自己的函数从而在特定时刻插入自定义逻辑。这些钩子函数按功能被分成了好几大类引导钩子、初始化钩子、用例收集钩子、用例执行钩子、报告钩子等。pytest_collection_modifyitems就属于“用例收集钩子”这一类。它的触发时机非常明确在pytest通过pytest_collection完成对所有测试用例的探索和收集之后在pytest_collection_finish之前被调用。此时session.items这个列表里已经装满了本次运行所有待执行的测试用例对象pytest_collection_modifyitems就是我们处理这个列表的黄金时机。2.2pytest_collection_modifyitems的触发时机与参数这个钩子函数的签名是固定的pytest_collection_modifyitems(session, config, items)。三个参数都是pytest运行时核心对象的引用session: 这是pytest.Session的一个实例代表本次测试会话。它最重要的属性就是session.items这是一个包含所有已收集测试用例对象的列表。我们操作的核心就是这个列表。config:pytest.Config对象包含了所有命令行参数、配置文件读取的选项等。我们可以通过它来获取运行时的配置从而让钩子逻辑动态化。例如判断用户是否传入了--order参数来决定是否排序。items:这是一个session.items的引用。直接修改这个items列表就是修改最终要执行的用例列表。这是该钩子函数发挥作用的直接途径。它的执行位置决定了其影响力所有基于目录、文件名、类名、函数名收集规则得到的用例都会经过它的处理。之后pytest才会进入用例执行循环pytest_runtestloop。因此在这里做的任何修改都会直接影响测试的执行行为。3.pytest_collection_modifyitems的典型应用场景与实操理论说了这么多到底怎么用我们直接看代码。你需要将钩子函数实现在你的conftest.py文件中这个文件应该放在你测试项目的根目录或者任何需要其生效的测试目录的父级目录中。3.1 场景一自定义测试用例执行顺序这是最经典的需求。pytest默认的收集顺序也即执行顺序可能不符合你的业务逻辑。需求示例我们有一个API测试项目测试用例需要按照登录 (login)-创建订单 (create_order)-查询订单 (query_order)-删除订单 (delete_order)的顺序执行。实现方案我们可以在用例的模块、类或函数上使用自定义标记mark然后在钩子中根据标记来排序。首先在测试用例中打上标记# test_order.py import pytest pytest.mark.order(1) def test_login(): assert True pytest.mark.order(2) def test_create_order(): assert True pytest.mark.order(3) def test_query_order(): assert True pytest.mark.order(4) def test_delete_order(): assert True # 另一个文件 test_payment.py 但支付依赖订单存在 pytest.mark.order(5) def test_pay_order(): assert True然后在conftest.py中实现排序逻辑# conftest.py def pytest_collection_modifyitems(session, config, items): 根据自定义的 order mark 对测试用例进行排序。 # 1. 创建一个映射关系用例对象 - 顺序值 item_mapping [] for item in items: # 获取用例上的 order mark如果没有则默认为一个很大的数如9999放到最后执行 order_marker item.get_closest_marker(order) if order_marker: # mark 可以传参数如 pytest.mark.order(1)这里取第一个参数 order order_marker.args[0] if order_marker.args else 9999 else: order 9999 item_mapping.append((order, item)) # 2. 根据顺序值排序 item_mapping.sort(keylambda x: x[0]) # 3. 将排序后的用例对象写回 items 列表 # 注意需要清空原列表再扩展或者直接切片赋值。这里选择直接重新赋值。 # 但为了不影响外部引用更安全的方式是修改原列表内容。 items[:] [item for _, item in item_mapping] # 可选打印一下排序后的用例名便于调试 print(排序后的用例执行顺序) for item in items: print(f {item.nodeid})执行与效果运行pytest -v你会看到用例严格按照test_login,test_create_order,test_query_order,test_delete_order,test_pay_order的顺序执行。这个方法比依赖pytest-ordering插件更轻量也更灵活你可以定义任何你想要的排序逻辑比如按模块名、按类名、甚至按用例名的某种模式。注意直接修改items[:]是最稳妥的方式它确保了原始列表对象的引用不变只是内容被替换了。有些教程会写items.sort(key...)但这依赖于items本身是列表且sort方法可用而items[:] ...的写法兼容性更好意图也更清晰。3.2 场景二动态添加、过滤或标记测试用例有时候我们可能不想运行所有收集到的用例或者想给某些用例动态加上标记。需求示例1只运行上次失败的用例。这需要结合pytest的--lflast-failed参数但我们可以用钩子模拟或增强该行为。更常见的场景是根据命令行参数动态过滤用例。实现方案假设我们想通过一个自定义命令行参数--runslow来控制是否运行标记为slow的慢用例。首先在conftest.py中添加命令行选项# conftest.py def pytest_addoption(parser): parser.addoption( --runslow, actionstore_true, defaultFalse, help运行标记为 slow 的测试用例 )然后在pytest_collection_modifyitems中根据这个选项过滤用例# conftest.py def pytest_collection_modifyitems(config, items, session): # 注意这里参数顺序按照pytest约定实际接收时是 (session, config, items) # 但我们在函数定义时按此顺序pytest会自动匹配。 if not config.getoption(--runslow): # 如果不运行慢用例则移除所有标记了 slow 的用例 skip_slow pytest.mark.skip(reason需要 --runslow 选项来执行) for item in items: if slow in item.keywords: # 检查用例是否有 slow 标记 item.add_marker(skip_slow) # 动态添加 skip 标记需求示例2根据环境变量过滤用例。比如在CI环境中只跑冒烟测试。# conftest.py import os def pytest_collection_modifyitems(config, items): if os.environ.get(CI_ENV) true: # 在CI环境中只保留标记为 smoke 的用例 non_smoke_items [] smoke_items [] for item in items: if smoke in item.keywords: smoke_items.append(item) else: non_smoke_items.append(item) # 将非冒烟用例标记为跳过 skip_non_smoke pytest.mark.skip(reason非CI环境冒烟测试跳过) for item in non_smoke_items: item.add_marker(skip_non_smoke) # 理论上也可以直接 items[:] smoke_items但跳过更友好报告里能看到被跳过的用例数。3.3 场景三批量修改测试用例的属性每个item用例对象有很多有用的属性我们可以在执行前批量修改它们。一个非常实用的场景解决Allure报告中参数化用例标题换行问题。当使用pytest.mark.parametrize时如果参数值较长生成的用例标题在Allure报告里可能会因为包含参数而变得很长导致显示换行不美观。我们可以用pytest_collection_modifyitems来统一美化这些标题。# test_example.py import pytest pytest.mark.parametrize(username, password, [ (very_long_username_for_testing_purpose_123, strong_password_456), (admin, admin123), ]) def test_login_with_params(username, password): assert username and password默认情况下Allure报告中的用例名会是test_login_with_params[very_long_username_for_testing_purpose_123-strong_password_456]很可能换行。我们在conftest.py中修改# conftest.py def pytest_collection_modifyitems(items): for item in items: # 检查用例是否来自参数化 if hasattr(item, callspec): # callspec 是参数化用例特有的属性 # 获取原始用例名和参数ID original_name item.originalname or item.name param_id item.callspec.id # 构建一个更简洁的标题例如只取参数值的部分字符 # 这里假设参数化是两个参数我们简单处理实际应用可能需要更复杂的逻辑 new_name f{original_name}[{param_id[:10]}...] if len(param_id) 15 else f{original_name}[{param_id}] # 修改item的name属性这会影响报告中的显示 item.name new_name # 同时为了兼容性也修改一下 nodeid 中的显示名部分可选复杂 # item._nodeid ... # 直接修改nodeid需谨慎可能影响其他逻辑另一个常见属性是item.user_properties它可以用来给Allure报告附加额外的信息。def pytest_collection_modifyitems(items): for item in items: # 为所有用例添加一个自定义属性例如模块路径 item.user_properties.append((module, item.module.__name__)) # 如果用例有特定的mark可以添加更多信息 if api in item.keywords: item.user_properties.append((test_type, API))3.4 场景四与pytest-xdist分布式插件配合当使用pytest-xdist进行并行测试时pytest_collection_modifyitems会在主进程master收集完所有用例后被调用一次。这意味着你在钩子中对items的排序或过滤会影响到分发给各个子进程worker的用例列表。这是一个非常重要的特性。应用如果你有需要严格顺序执行的用例如场景一在并行模式下你仍然需要先通过这个钩子进行排序。然后xdist插件会负责将排序后的列表分发给各个worker。但是要注意跨进程的用例状态如登录态是无法共享的所以依赖状态的用例不适合拆到不同worker并行执行你需要在钩子或通过其他方式如pytest.mark.xdist_group将它们分到同一个组确保被同一个worker执行。4. 高级技巧与避坑指南用了几年pytest_collection_modifyitems我踩过不少坑也总结出一些让代码更健壮、更高效的经验。4.1 性能考量操作大型用例集当你的测试套件有成千上万个用例时pytest_collection_modifyitems中的循环操作就需要考虑性能了。一些建议避免在钩子中进行复杂的I/O操作或网络请求。收集阶段应该快速完成。对items列表的排序操作sort是O(n log n)复杂度对于超大列表如果排序逻辑本身很复杂比如每次比较都要解析字符串可能会成为瓶颈。尽量使用简单的键key函数。谨慎使用item.module或item.function等属性。访问这些属性可能会触发模块导入如果还没导入的话在收集阶段大量导入模块可能会稍慢但通常可以接受。如果确实有性能问题可以考虑使用item.nodeid字符串来提取信息它不需要导入模块。4.2 执行顺序的陷阱与确定性排序你可能会想我用items.sort(keylambda x: x.nodeid)按节点ID字母排序不就能保证每次顺序一样了吗是的这能提供确定性但未必是正确的业务顺序。更关键的是当与pytest-xdist并行时每个worker得到的用例切片顺序是确定的但不同worker间的执行顺序依然是并发的、不确定的。如果你需要绝对的、全局的顺序并行测试可能不是最佳选择或者你需要设计更复杂的分组逻辑。一个技巧是使用item.get_closest_marker获取的标记信息来排序这比解析nodeid字符串更可靠因为nodeid的格式path/to/file.py::TestClass::test_method可能因收集器不同而有细微差别。4.3 钩子函数的加载与作用域conftest.py中的钩子函数有其作用域。定义在项目根目录conftest.py中的pytest_collection_modifyitems会对所有子目录的测试生效。如果你在子目录也定义了一个同名的钩子那么两个钩子都会被执行pytest会按照插件系统规则收集它们。通常离测试文件更近的conftest.py中的钩子会后执行。这有时会导致意想不到的覆盖行为。我的建议是对于全局性的修改如排序、过滤尽量只在项目根目录的conftest.py中实现避免冲突。4.4 调试钩子函数调试钩子函数尤其是它修改items的效果一个简单的方法是在函数末尾打印items列表。def pytest_collection_modifyitems(session, config, items): # ... 你的修改逻辑 ... if config.getoption(verbose) 0: # 配合 -v 参数输出 print(f\n修改后用例数量: {len(items)}) for i, item in enumerate(items[:5]): # 只打印前5个避免刷屏 print(f {i1}: {item.nodeid})更高级的调试可以使用Python的pdb或者在钩子中引发一个异常来暂停但这会影响正常测试流程。5. 实战一个完整的测试用例智能调度示例让我们结合多个场景构建一个相对完整的conftest.py示例它实现了通过--runorder参数指定排序方式none, name, custom。通过--include-tag和--exclude-tag动态过滤用例。美化参数化用例在报告中的名称。# conftest.py import re import pytest def pytest_addoption(parser): group parser.getgroup(custom ordering and filtering) group.addoption( --runorder, actionstore, defaultnone, choices[none, name, custom], help测试用例执行顺序none(默认), name(按名称), custom(按自定义mark) ) group.addoption( --include-tag, actionappend, default[], help只运行包含指定标记的用例可多次使用 ) group.addoption( --exclude-tag, actionappend, default[], help跳过包含指定标记的用例可多次使用 ) def pytest_collection_modifyitems(session, config, items): 核心调度钩子过滤、排序、修改属性。 # 阶段1基于标签过滤 include_tags config.getoption(--include-tag) exclude_tags config.getoption(--exclude-tag) items_to_keep [] items_to_skip [] for item in items: item_keywords {marker.name for marker in item.iter_markers()} # 排除逻辑如果用例有任何一个 --exclude-tag 中的标记则跳过 if any(excluded_tag in item_keywords for excluded_tag in exclude_tags): items_to_skip.append(item) continue # 包含逻辑如果指定了 --include-tag则用例必须至少包含其中一个标记 if include_tags and not any(included_tag in item_keywords for included_tag in include_tags): items_to_skip.append(item) continue items_to_keep.append(item) # 给需要跳过的用例添加 skip 标记 if items_to_skip: skip_marker pytest.mark.skip(reason被标签过滤排除) for item in items_to_skip: item.add_marker(skip_marker) # 更新待处理列表为保留的用例 filtered_items items_to_keep # 阶段2排序 order_mode config.getoption(--runorder) if order_mode name: # 按节点ID的字符串排序近似按文件名、类名、方法名排序 filtered_items.sort(keylambda x: x.nodeid) elif order_mode custom: # 按自定义的 order mark 排序类似前面例子 def get_order(item): marker item.get_closest_marker(order) return marker.args[0] if marker and marker.args else 9999 filtered_items.sort(keyget_order) # order_mode none 则不排序 # 阶段3美化用例名针对参数化用例 for item in filtered_items: if hasattr(item, callspec): # 简化参数化ID的显示例如将长字符串截断 original_name item.originalname or item.name param_id item.callspec.id # 移除可能过长的参数值显示只保留简略信息 if len(param_id) 20: # 简单截断更复杂的可以提取关键部分 short_id param_id[:15] ... item.name f{original_name}[{short_id}] # 可以在这里为Allure报告添加额外的摘要属性 if hasattr(item, user_properties): # 记录原始参数ID便于追溯 item.user_properties.append((param_id, param_id)) # 关键步骤将处理后的列表写回原始 items # 我们需要保留被跳过的用例在列表末尾这样报告里还能看到它们被跳过了 items[:] filtered_items items_to_skip # 可选打印摘要信息 if config.getoption(verbose) 0: print(f\n[自定义调度] 模式: {order_mode}, 包含标签: {include_tags}, 排除标签: {exclude_tags}) print(f 保留用例数: {len(filtered_items)}, 跳过用例数: {len(items_to_skip)})这个示例展示了如何将多个功能整合到一个钩子中并通过命令行参数灵活控制。在实际项目中你可能需要根据团队规范进行调整比如自定义标记的命名、排序算法的优化等。最后记住pytest_collection_modifyitems是一个强大的工具但“能力越大责任越大”。过度复杂的逻辑可能会让测试行为难以理解和调试。始终确保你的修改逻辑清晰、有文档记录并且不会破坏pytest本身的其他特性如夹具依赖、用例发现等。当你需要对测试生命周期进行精细控制时它几乎总是你的第一选择。