
pytest 在 jenkins 环境中跳过参数化测试,根本原因在于测试收集阶段(Collection phase)早于工作区资源就绪,而 jenkins 清理工作区导致 `@pytest.mark.parametrize` 中调用的 `get_asset()` 提前返回空列表;需将动态资产发现逻辑移至 `pytest_sessionstart` 等会话级钩子中。
在使用 pytest 进行参数化测试时,若测试函数依赖运行时动态生成的参数(如从文件系统读取的测试资产),极易在 CI 环境(尤其是 Jenkins)中出现「本地能跑、Jenkins 跳过」的诡异现象。其本质并非 Jenkins 本身限制,而是 pytest 的测试收集机制与CI 工作流时序发生冲突所致。
? 问题根源解析
pytest 在执行任何测试前,会先进入 collection 阶段:静态扫描所有测试模块,解析 @pytest.mark.parametrize、@pytest.fixture 等装饰器,并立即求值其中的参数表达式(如 get_asset())。此时:
- 若 get_asset() 依赖磁盘上的 asset/ 目录,则该目录必须在 collection 阶段已存在且可访问;
- Jenkins 默认启用 “delete workspace before build starts”,导致每次构建开始时工作区为空;
- 因此 collection 阶段调用 get_asset() 返回空列表 → pytest 认为无参数可迭代 → 整个 test_app_launch_asset 被静默跳过(显示为 skipped 或甚至不显示);
- 而手动登录 Jenkins 机器后执行命令时,工作区已被前次构建残留的 asset/ 填充,故 get_asset() 正常返回 → 测试正常执行。
⚠️ 注意:这不是 get_asset 函数逻辑错误,而是执行时机错配——它被当作“编译期常量”求值,实则应是“运行期动态数据”。
✅ 正确解决方案:使用 pytest 会话级钩子预加载参数
应避免在 @parametrize 中直接调用 IO 密集型函数。推荐将资产发现逻辑提前至 pytest_sessionstart(在 collection 之前执行),并将结果缓存到 config 对象中供后续使用:
# conftest.py import pytest from pathlib import Path def get_asset() -> list[Path]: """安全版资产发现:确保路径存在且可读""" asset_dir = Path(__file__).parent / 'asset' if not asset_dir.exists(): return [] return [ p for p in asset_dir.iterdir() if p.is_file() and 'need_to_skip_asset' not in p.name ] def pytest_sessionstart(session): """在测试收集前执行:预加载资产列表并挂载到配置""" assets = get_asset() session.config._metadata['available_assets'] = assets # 可选:用于报告 # 将资产列表注入全局变量或 session 属性(推荐) session.assets = assets # 在测试文件中改写参数化逻辑 @pytest.fixture(scope='session', autouse=True) def available_assets(request): """提供会话级 fixture,确保资产列表在测试中可用""" return request.session.assets @pytest.mark.parametrize('asset', [], indirect=True) # 占位符,实际由 fixture 提供 def test_app_launch_asset(app_binary, asset, available_assets): """实际测试逻辑 —— 参数由 fixture 动态注入""" print(f'Application: {app_binary}') print(f'Asset: {asset}') applib.execute( cmd=[str(app_binary), str(asset)], timeout=15, )
但更简洁、符合 pytest 惯例的方式是:完全弃用 @parametrize 的函数调用形式,改用 indirect + fixture 组合:
# test_app.py import pytest @pytest.fixture(params=[]) # 空占位,真实参数由 conftest.py 注入 def asset(request): # 此处可访问 session.assets(需在 conftest.py 中设置) session = request.session if not hasattr(session, 'assets'): pytest.skip("No assets found — check asset directory existence") return session.assets[request.param] # 重写 parametrize:传入索引而非对象 @pytest.mark.parametrize('asset', list(range(100)), indirect=True) def test_app_launch_asset(app_binary, asset): print(f'Application: {app_binary}') print(f'Asset: {asset}') applib.execute(cmd=[str(app_binary), str(asset)], timeout=15)
不过最推荐的工业级实践是:在 conftest.py 中定义一个 session-scoped fixture,返回完整资产列表,再在测试中通过 for 循环显式遍历(牺牲少量 pytest 原生参数化语法糖,换取完全可控性):
# conftest.py import pytest from pathlib import Path @pytest.fixture(scope='session') def all_assets(): asset_dir = Path(__file__).parent / 'asset' if not asset_dir.exists(): pytest.skip(f"Asset directory missing: {asset_dir}") return [ p for p in asset_dir.iterdir() if p.is_file() and 'need_to_skip_asset' not in p.name ] # test_app.py def test_app_launch_asset(app_binary, all_assets): """单测试函数内遍历所有资产 —— 完全规避 collection 时序问题""" for asset in all_assets: print(f'Running on asset: {asset}') applib.execute(cmd=[str(app_binary), str(asset)], timeout=15)
? 关键注意事项
- ✅ 永远不要在 @pytest.mark.parametrize(…) 的参数表达式中执行 IO 操作(如读文件、查数据库、调用外部命令);
- ✅ pytest_sessionstart 和 pytest_configure 是仅有的两个在 collection 之前触发的钩子,适合做预热准备;
- ✅ Jenkins 构建日志中若看到 collected 0 items,基本可断定 collection 阶段参数源为空;
- ✅ 在 conftest.py 中添加 print() 或日志输出,验证钩子是否被触发(注意 Jenkins 控制台编码与缓冲);
- ✅ 本地调试时,可临时在 get_asset() 开头加入 assert Path(‘asset’).exists(),快速暴露环境差异。
通过将动态数据获取逻辑与 pytest 的生命周期对齐,即可彻底解决 Jenkins 下测试“神秘跳过”的问题,让 CI 行为与本地开发保持一致、可预测、可调试。