如何解决 pytest 在 Jenkins 中跳过测试但本地正常执行的问题

23次阅读

如何解决 pytest 在 Jenkins 中跳过测试但本地正常执行的问题

pytestjenkins 环境中跳过参数化测试,根本原因在于测试收集阶段(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 行为与本地开发保持一致、可预测、可调试。

text=ZqhQzanResources