☰
Selenium+pytest:批量执行、筛选重跑与 Allure 报告
2026/9/30 10:37:24 网站建设 项目流程

把 300 条 selenium 用例丢进流水线,第二天早上打开报告,红得整整齐齐——260 条失败,失败截图一模一样,全是登录页。这是几年前我第一次把 python 自动化测试 接到持续集成上时的真实场面。用例本身没问题,单条本地跑都过,坏就坏在"运行方式"和"测试报告"这两件事上:我把它们当成收尾工作,随便抓了个 HTMLTestRunner 就交差,结果既不知道自己错在哪,也说不清到底哪些用例真的挂了。

这一篇接着前面的内容往下走,前面二十几篇我们把 selenium 的元素定位、显式等待、Page Object 拆分都过了一遍,轮到最后一公里:怎么把散落在各个文件里的测试用例批量跑起来,怎么让它跑得可控、可筛选、可重跑,以及怎么生成一份能让产品经理和 leader 都看懂的测试报告。内容默认你已经会用 python 写函数和类、用过装饰器、会建虚拟环境,如果你连pip install都还没跑通过,建议先补一下基础再来。下面所有代码和配置都是我在实际项目里跑过的,命令可以直接抄。

1. 运行之前先把"家"收拾好:目录约定与用例发现规则

1.1 一套能撑住两百条用例的目录结构

很多人写 selenium 脚本的习惯是:一个文件夹里塞十几个.py,每个文件里一堆函数,跑的时候挨个点运行。到三十条用例还能忍,到一百条就是灾难——改一个元素定位要翻八个文件,报告里全是文件名加函数名的长串,根本看不出业务含义。

我在项目里固定用下面这套结构,从两百条撑到过千条都没换过:

project/ ├── conftest.py # 根级 fixture 与全局 hook ├── pytest.ini # pytest 配置,标签注册在这里 ├── requirements.txt ├── pages/ # 页面对象,只放元素定位和操作 │ ├── base_page.py │ └── login_page.py ├── tests/ # 只放用例,写业务断言 │ ├── test_login.py │ └── test_order.py ├── utils/ # driver 工厂、日志、读数据 │ ├── driver_factory.py │ └── logger.py ├── data/ # 测试数据,yaml/json │ └── users.yaml ├── logs/ # 运行日志,不进版本库 ├── screenshots/ # 失败截图,不进版本库 └── reports/ # 报告产物,不进版本库

pages和tests分离是这套结构的核心。页面对象里只有"元素怎么找、点什么按钮",用例里只有"点了之后应该看到什么",两者改动的理由完全不同。经验上看,一个商城项目里元素定位的变动频率是用例逻辑的五六倍,分开了以后,UI 改版只改pages,用例文件基本不动。

logs、screenshots、reports三个目录一定要写进.gitignore。我见过不止一个团队把每次运行生成的 Allure 报告提交上去了,仓库半年涨到几个 G,克隆一次要等十分钟。

1.2 pytest 的用例发现规则与命名红线

pytest 默认的收集规则就三条:文件名匹配test_*.py或*_test.py,类名以Test开头,函数名以test_开头。看起来简单,实际踩坑最多的是类里面写了__init__:

class TestLogin: def __init__(self): self.driver = None # 这条用例会被 pytest 直接跳过,且不报错

pytest 遇到带__init__的测试类会静默跳过整类,因为它的实例化方式和普通类不同。这个坑最阴的地方在于:本地跑的时候你可能只看了"没有失败",以为全过了,实际上一条都没执行。我现在一律用--collect-only -q先看收集数量,跑之前先确认条数对得上。

命名上我给团队定的规矩是"读出来就是一句话":test_login_with_wrong_password而不是test_case_03。报告是给人看的,test_case_03挂了,翻代码要三分钟;test_login_with_wrong_password挂了,心里立刻有数。

1.3 全局初始化放在 conftest.py,而不是每条用例里

driver 的创建和销毁一定要用 fixture 管理,写在conftest.py里,用例只管声明参数:

# conftest.py import pytest from utils.driver_factory import build_driver @pytest.fixture(scope="function") def driver(): d = build_driver() yield d d.quit()

这里有两个决定要解释清楚。第一个是scope="function"。有人为了省时间改成session,整个测试会话共用一个浏览器实例,启动快了,但用例之间会互相污染:上一条用例留在购物车里的商品,下一条用例结算时会带出来,表现为"单独跑能过,一起跑就挂"。我建议默认 function 级别,只有确实无状态的模块(比如纯页面文案校验)才用session。

第二个是quit()的位置。它必须写在yield之后,而且不要在中间包一层 try 把异常吞掉。yield之前的代码是 setup,之后的代码是 teardown,即使用例断言失败,teardown 也会执行。但如果你在 teardown 里写了个try: d.quit() except: pass,浏览器进程偶尔残留,几十次运行下来机器上堆积一堆 driver 进程,内存被吃满,后面的用例就开始莫名其妙超时。

2. 从单条调试到批量执行:命令行里真正有用的几个参数

2.1 精确打击:节点 ID、-k和-m三种筛选的分工

调用例的时候最忌讳"跑全部"。pytest 提供了三种精确打击的方式,我用它们的分工是这样的:

方式写法我什么时候用它
节点 IDpytest tests/test_login.py::TestLogin::test_login_success正在改某一条用例,本地反复调试
-k表达式pytest -k "login and not sms"按名字模糊匹配,临时筛一批
-m标签pytest -m smoke长期稳定的分组,配合流水线

节点 ID 最好记,文件::类::方法三段,VS Code 里选中函数名右键复制引用路径也能拿到。-k支持and、or、not,调试登录模块又不想跑短信验证码那条,-k "login and not sms"一行搞定。

真正要在团队里推广的是-m。标签必须先在pytest.ini里注册:

[pytest] testpaths = tests python_files = test_*.py python_classes = Test* python_functions = test_* addopts = -v -ra --strict-markers markers = smoke: 冒烟用例,每次提交都跑 regression: 全量回归,每天夜间跑 slow: 单条耗时超过 30 秒

--strict-markers这个开关我强烈建议加上。没有它的时候,你把@pytest.mark.somke拼错成somke,pytest 不会报错,-m smoke会收集到 0 条用例,然后输出"0 passed",退出码是 5。很多流水线脚本只看"有没有 failed",于是这个空跑被当成绿灯放过,一整周的回归实际什么都没测。加上--strict-markers之后,未注册的标签直接报错中断。

2.2 失败重跑与"一挂就停":几个参数怎么配合

批量跑的时候有两个方向相反的需求:一是想知道"到底有多少种问题",这时候一挂就停最省时间;二是怀疑某条用例本身不稳定,想让它重跑确认。对应的参数是这样的:

  • -x:遇到第一个失败立即停止,适合本地改 bug 时快速定位。
  • --maxfail=3:失败 3 条后停止,比-x实用,一次能看到几个不同的问题。
  • --reruns 2 --reruns-delay 1:失败用例重跑两次,每次间隔 1 秒,需要装pytest-rerunfailures。

重跑这个功能要谨慎用。它的初衷是应对网络抖动、动画未完成这类偶发问题,但如果你把重跑加到"所有失败都重试三次",等于把不稳定用例永久掩盖了——一条每次都要重跑两次才过的用例,跟一条必须重启电脑才能跑过的用例,本质上是同一类问题。我的做法是只在流水线上开--reruns 1,本地不开;同时要求每周看一次报告里"首次失败但重跑通过"的用例清单,逐条去治。Allure 会单独标注重跑记录(retries),这个信息非常有价值,别忽略。

2.3 并行跑用例之前,先解决 driver 与数据的隔离

用例上到几百条,串行跑一轮要四十分钟,很自然会想到pytest-xdist:

pytest -n 4

四个 worker 并行,理论上快四倍。但我第一次上并行,挂了三分之二,原因有三类:

第一类是类级别共享 driver。有些用例用scope="class"的 fixture 存 driver,在 xdist 下每个 worker 是独立进程,类级别 fixture 会在每个 worker 里各建一份,看似没问题,但如果你在类里维护了cls.counter这种可变状态,跨用例的数值就对不上了。

第二类是数据冲突。四条用例同时用同一个账号登录同一个系统,服务端的会话互相顶掉,表现为随机失败。解决办法是给每个 worker 准备独立账号池,或者用用例参数化的方式把账号按索引分开。

第三类是文件冲突。失败截图如果按"模块名+时间戳"命名,同一秒内多个 worker 生成同名文件,互相覆盖,最后只剩一张。文件名里必须带唯一标识,用uuid4().hex或者os.getpid()都行。

我的一般建议是:先把串行跑稳两周,报告连续绿了再上并行,而且第一次开-n 2,观察一周再往上加。

3. 测试报告怎么选:三种方案的真实差异

3.1 选型对照表:别在 HTMLTestRunner 上花太多时间

市面上的报告方案,我实际用过的就这三种,差异比想象中大:

方案安装成本观感步骤与附件历史趋势适合阶段
HTMLTestRunner低单页 HTML,样式老旧基本没有无学习练手
pytest-html一行 pip清爽简洁附件支持一般无本地快速验证
Allure中,需额外命令行工具强,层级清晰步骤、附件、参数齐全有团队协作长期用

HTMLTestRunner 本身是 unittest 生态的产物,网上流传的版本大多是别人为了兼容新版本 python 自己改过的,改法五花八门,出了问题很难找到答案。如果你的用例是用 pytest 组织的,它和 HTMLTestRunner 之间本身就不顺,硬接不如不接。我现在只在给别人演示"报告长什么样"的时候用它。

pytest-html 胜在零配置:

pip install pytest-html pytest --html=./reports/report.html --self-contained-html

--self-contained-html会把 CSS 内联进去,生成一个文件就能直接双击打开,也能直接当附件发出去。缺点是它按用例平铺,没有"模块-场景-用例"的层级,附件展示也比较粗糙,用例一多就显得杂乱。

选 Allure 的理由只有一条:它把报告做成了可以按 feature/story 折叠的树,每一步操作都能展开看,失败截图和日志挂在具体那一步下面。用例超过一百条以后,这个层级的价值是指数级上升的。

3.2 Allure 落地:从安装到生成第一份带步骤的报告

Allure 要装两样东西,很多人只装了第一样然后困惑"命令找不到"。

第一样是 python 侧插件:

pip install allure-pytest

第二样是命令行工具,这个是独立于 python 的可执行程序,需要下载后解压、把bin目录加到系统 PATH 里,它自己依赖 Java 运行环境,所以机器上还得有 JRE。这两步做完,allure --version能输出版本号才算通。

然后就是固定的两条命令:

pytest --alluredir=./reports/allure-results --clean-alluredir allure generate ./reports/allure-results -o ./reports/allure-report --clean

第一步跑用例,把原始结果写到allure-results;第二步把这些零散的 JSON 渲染成 HTML 站点到allure-report。

注意:不要直接双击打开allure-report/index.html。报告页面需要通过 fetch 去读同目录下的数据文件,浏览器对本地文件的安全限制会拦住这个请求,你看到的是一张空白页或者乱码骨架。要么用allure serve ./reports/allure-results起一个临时服务,要么用python -m http.server在报告目录下起个静态服务再访问。这个坑几乎每个第一次用 Allure 的人都踩过。

--clean-alluredir这个参数我建议每次都带上。allure-results里如果混着上一轮的旧文件,生成出来的报告会莫名其妙多出几条消失的用例,或者趋势数字翻倍。

3.3 报告里的层级:feature、story、title 怎么用才不乱

Allure 装饰器一共就那么几个,用对了报告是一棵树,用错了是一锅粥。我的用法是:

import allure @allure.feature("登录模块") class TestLogin: @allure.story("账号密码登录") @allure.title("正确账号密码可以登录成功") @allure.severity(allure.severity_level.CRITICAL) def test_login_success(self, driver): page = LoginPage(driver) page.open() page.input_username("tester") page.input_password("abc123456") page.click_submit() assert page.is_logged_in()

feature对应业务大模块,story对应模块下的具体场景,类上加 feature、方法上加 story,层级就出来了。severity我用四级:CRITICAL 是核心链路(登录、下单、支付),NORMAL 是普通功能,MINOR 是文案和样式校验,TRIVIAL 是提示语这类。报告里按严重级别筛选,出问题的时候先看 CRITICAL,能省掉大量翻找时间。

真正让报告"活起来"的是@allure.step。把它加在 Page Object 的方法上,报告里就会自动展开每一步:

import allure class LoginPage: @allure.step("输入用户名 {username}") def input_username(self, username): self.find(self.username_input).clear() self.find(self.username_input).send_keys(username)

注意花括号{}里的变量名必须和参数名一致,它会在报告里替换成实际传入的值。这样一条用例失败,你打开报告能看到"输入用户名 tester → 输入密码 → 点击提交 → 断言失败"这样的时间线,而不是只有一行"AssertionError"。

还有一点值得提前提醒:@allure.title一定要保证在同一次运行里唯一。Allure 是靠用例的完整标识加参数来算历史趋势的,两条不同用例用了同一个 title,趋势图上就会被合并成一条,数字看着就是错的。我一般只在 title 里写清楚业务语言,不加编号,因为类名加方法名本身已经唯一了。

4. 失败现场还原:截图、日志、DOM 的工程化挂载

4.1 一个 hook 解决所有用例的失败截图

逐个用例里写"如果失败就截图"是最笨的做法,而且会漏。正确的位置是 pytest 的钩子,写在根目录conftest.py里,全局生效:

import allure import pytest @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_runtest_makereport(item, call): outcome = yield report = outcome.get_result() if report.when == "call" and report.failed: driver = item.funcargs.get("driver") if driver is None: return try: allure.attach( driver.get_screenshot_as_png(), name="失败截图", attachment_type=allure.attachment_type.PNG, ) allure.attach( driver.page_source, name="失败页面 DOM", attachment_type=allure.attachment_type.HTML, ) except Exception: pass

几个细节得说清楚。hookwrapper=True表示这是一个包装器,yield之后才能拿到执行结果,outcome.get_result()就是报告对象。report.when == "call"这个判断很关键,pytest 的阶段分 setup、call、teardown 三段,分别对应准备、执行、清理;只在call阶段截图,才能保证页面处于用例真正失败的那一瞬间。如果你不加这个判断,setup 阶段因为环境问题失败时也会触发截图,那时候浏览器可能压根没打开,driver是 None,直接抛异常把整轮跑挂掉。

item.funcargs.get("driver")是从当前用例的参数里取 driver 实例。前提是你的 fixture 名字就叫driver,如果项目里用的是browser或web_driver,这里要改成对应的名字。

4.2 截图是白屏?先检查它和 quit 的先后顺序

失败截图白屏、或者只有一片灰色,是新手最常报的问题,原因基本跑不出下面三种:

第一,driver 已经被关掉了。如果你的 fixture 写成先quit()再yield,那用例执行的时候浏览器早就没了,截图只能拍到白板。顺序永远是"创建 → yield → 操作 → quit"。

第二,窗口尺寸是 0。headless 模式下如果不设置窗口大小,某些版本的浏览器截图出来是 1x1 像素。在 driver 工厂里统一加一句就好:

options.add_argument("--window-size=1920,1080")

第三,失败发生在页面跳转中间。用例点了个按钮触发了页面跳转,断言在跳转完成前执行,截图拍下来是新旧页面交替的白屏。这一类严格说不是截图的问题,是用例没等页面对齐,属于上一篇文章里显式等待的范畴,但截图能帮你快速识别出来——连续多条用例截图都是同一个半加载状态,说明缺的是WebDriverWait,不是截图逻辑。

还有一个隐蔽的坑:截图文件名重复导致互相覆盖。如果你不用 Allure 的attach而是自己save_screenshot到文件,文件名里一定要带用例标识。我习惯用item.nodeid.replace("/", "_").replace("::", "__")拼出来,路径安全又唯一。

4.3 日志和 DOM 快照:比截图更值钱的两类附件

截图能看出来的东西其实有限。元素没找到的时候,截图只能告诉你"这里没有嘛",看不出 DOM 里到底是什么状态。所以从第二年开始,我在失败附件里固定加三样:截图、当前页面 DOM、该用例的日志片段。

日志的做法是在conftest.py里配一个FileHandler,每个用例一条独立的日志文件,文件名用nodeid拼,用例结束的时候判断是否失败,失败就把文件内容读出来 attach 上去:

import logging from pathlib import Path def attach_case_log(item, name="运行日志"): path = Path("logs") / (item.nodeid.replace("/", "_").replace("::", "__") + ".log") if not path.exists(): return text = path.read_text(encoding="utf-8", errors="ignore") allure.attach(text[-8000:], name=name, attachment_type=allure.attachment_type.TEXT)

text[-8000:]这个截断很重要。一条复杂用例的日志可能有几百 KB,全塞进报告里,报告文件会膨胀到几十兆,浏览器打开直接卡死。只保留最后八 KB,也就是失败前的那一段,信息密度最高。

DOM 快照则解决"元素定位过时"的问题。Allure 里 HTML 类型的附件是可以直接预览的,你点开就能看到当时页面真实的标签结构,配合<div><ul><li>这种非原生下拉框组合结构,一眼就能判断是定位写错了还是页面确实变了。

5. 报告接上流水线之后:趋势、归档与几个长期要防的坑

5.1 让 Allure 的"趋势图"真的画出趋势

Allure 报告首页左侧有个 Trends 面板,能看到通过率、耗时随时间的变化曲线,这是它最实用的功能之一。但很多人第一次用发现那里只有孤零零的一根柱子,原因在于趋势数据不会自动继承。

每轮生成报告时,Allure 是从allure-results/history目录读取历史数据的。所以第二轮跑之前,要先把上一轮报告里的 history 拷进来:

if [ -d "./reports/allure-report/history" ]; then cp -r ./reports/allure-report/history ./reports/allure-results/ fi pytest --alluredir=./reports/allure-results allure generate ./reports/allure-results -o ./reports/allure-report --clean

顺序不能反:先拷 history,再跑用例。如果先跑再拷,拷贝过来的 history 会被--clean清掉,或者干脆被忽略。这个脚本我一般抽成一个run_tests.sh,本地和流水线共用同一份,避免"本地有趋势图线上没有"这种排查半天的问题。

5.2 报告产物不该进版本库:归档策略与命名

顺着上面说,reports/必须进.gitignore。但光忽略还不够,还得有归档策略,否则磁盘会被撑爆。我的习惯是这样:

  • 本地:只保留最近一轮,脚本里带--clean自动清。
  • 流水线:每轮产物放到一个带时间戳的目录,比如reports/2025-06-11_1430/,通过流水线的 artifact 机制保留,一般留最近三十轮。
  • 报告目录里的history是唯一需要跨轮传递的东西,其余都可以重建。

时间戳命名还有个附带好处:出问题回溯的时候,能精确知道是哪一轮、哪个提交对应的报告。如果流水线能给报告目录带上 commit 短哈希,那几乎不用查日志就能定位到代码变更了。

5.3 三个长期要防的坑和我自己的兜底做法

第一个是"静默空跑"。前面提过-m标签拼错会收集到 0 条用例并且退出码是 5,很多 CI 脚本只判断"退出码是否为 0",反而把 5 当成了异常,但也有些脚本判断"有没有 failed",就把空跑当成通过。我的兜底是在流水线里加一步前置校验,用pytest --collect-only -q | tail -1拿到收集到的用例数,和预期下限做对比,低于阈值直接让这一步失败。

第二个是重跑掩盖问题。前面已经说过,这里补一个操作层面的做法:每周从 Allure 报告里筛一遍"重跑后通过"的用例,把它们列成一张清单,指定人去治。清单不做记录,这个问题就会一直存在,而且随着用例数量增长越来越难以统计。

第三个是报告文件名和路径里的中文与空格。Allure 生成的附件路径如果包含中文或者空格,某些静态服务在解析 URL 的时候会 404,表现为报告里附件点不开。对策很简单,项目路径、日志文件名、截图文件名全部只用 ASCII 字符,用户名的部分用拼音或者编号替代。

最后一个我想说的是退出码。pytest 的退出码有明确含义:0 是全部通过,1 是有失败,2 是被中断,5 是一条都没收集到。在 shell 里用pytest ... || exit 1这种写法要小心,它会把 5 也当成通过。稳妥的写法是显式判断:

pytest --alluredir=./reports/allure-results status=$? if [ "$status" -ne 0 ]; then echo "用例执行异常,退出码 $status" exit 1 fi

我个人的体会是,selenium 项目做到后面,用例写得漂不漂亮已经不太重要了——真正决定这个项目能不能活下去的,是每晚那份报告有没有人愿意打开、打开之后能不能在两分钟内看懂挂在哪。用例的组织方式、命令行的筛选参数、Allure 的层级设计、失败附件里那三样东西,都是为了这一个目标服务的。把报告做得能用,比多写五十条用例更有价值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询