简介:Selenium不仅是UI自动化测试利器,也能借助WebDriver与JavaScript执行能力模拟POST请求,完成异步接口的数据交互。这套示例包面向需要处理POST参数提交并解析JSON返回值的Selenium使用者,尤其适合在自动化脚本中临时构造请求、验证接口返回的中高级开发者。压缩包共3个文件,大小约5KB,包含txt格式的详细说明、Java示例源文件以及bak备份文件,便于对照阅读与二次修改。核心示例源码演示了如何构造POST数据、通过fetch发起请求、读取页面返回文本并解析为JSON对象,同时也提示了请求头、cookie、认证等实际场景中的封装要点,并讨论了execute_async_script处理异步响应的注意事项。资源已有3698人学习,对于想了解Selenium在HTTP请求方向的灵活用法、或希望快速套用POST封装代码的读者,是一份小巧实用的参考。
1. Selenium 封装 Post 参数提交:除了点点点,自动化测试还能怎么传数据?
你打开浏览器,输入账号密码,点击登录,等着页面跳转——这是绝大多数人对 UI 自动化的印象。但如果某天你拿到一个测试任务,需要在页面上批量提交几十组不同格式的 POST 数据,同时还要验证前端有没有正确地把这些参数渲染到表单里,手工一个个点显然不现实。我在做某电商后台系统的回归测试时就撞上过这种需求:接口文档里写明了请求体结构,但前端页面上没有对应的批量录入入口,直接调接口又测不到 UI 层的真实反馈。当时我的第一反应是放弃 UI 自动化,回到纯接口测试,但后来发现 Selenium 其实有办法把 POST 参数“塞”进浏览器会话里,只是官方文档讲得很隐晦。这篇笔记就从我踩过的坑出发,聊清楚怎么在 Selenium 框架内封装 POST 参数提交,让 UI 和接口测试在同一个脚本里跑通。
2. 为什么原生 Selenium 不适合直接发 POST:先理解它的请求模型
2.1 WebDriver 协议的本质是“操作浏览器”,不是“发起请求”
Selenium 的核心是 WebDriver 协议,它通过 HTTP 接口和浏览器驱动通信,指挥浏览器去点击、输入、跳转、截图。这套协议设计的初衷是模拟用户行为,而不是直接构造网络请求。所以当你调用driver.get(url)时,实际发生的是浏览器导航到某个地址,浏览器内部发起 GET 请求;而driver.find_element(...).click()也只是触发 DOM 元素的事件。原生 API 里没有“用 POST 方式打开一个网页”的方法。
我最初想在 Selenium 里直接发送 POST 请求,翻了官方文档,发现确实没有对应的接口。这其实不是 Selenium 的缺陷,而是定位决定的——它做的是浏览器自动化,不是 HTTP 客户端。但如果测试场景里必须让浏览器携带某些 POST 数据去访问页面,比如模拟一个带 JSON 体的表单提交,原生方案就失效了。
常见的绕行思路有两种。一种是直接调用 Python 的requests库发送 POST,再去操作页面验证结果,但这种做法等于把接口测试和 UI 测试割裂开了,浏览器并不知道你刚才发过什么请求。另一种是通过 JavaScript 执行器构造隐藏表单,用document.getElementById('form').submit()提交,但碰到跨域限制或者复杂请求体就会翻车。下面一节我会展开说这两种方案的边界在哪里。
2.2 用 JavaScript 执行器构造隐藏表单:思路可行,但坑不少
driver.execute_script()是 Selenium 留给开发者的一个后门,可以在当前页面上下文中运行任意 JavaScript 代码。利用这一点,可以在页面里动态创建一个<form>元素,把参数拼成<input>的name和value,然后调用submit()。这样页面会向服务器发出一个标准表单 POST 请求,浏览器也会伴随导航,等于完成了“带参数提交”的效果。
from selenium import webdriver import json def post_via_hidden_form(driver, url, data: dict): script = """ var form = document.createElement('form'); form.method = 'POST'; form.action = arguments[0]; form.style.display = 'none'; for (var key in arguments[1]) { if (arguments[1].hasOwnProperty(key)) { var input = document.createElement('input'); input.name = key; input.value = arguments[1][key]; form.appendChild(input); } } document.body.appendChild(form); form.submit(); """ driver.execute_script(script, url, data)这段代码的逻辑很清晰:执行脚本后页面会立即跳转,浏览器向url发起一个application/x-www-form-urlencoded格式的 POST 请求。参数说明:driver是已经初始化的浏览器实例;url是目标接口地址;data是 Python 字典,会被序列化成 JavaScript 对象传入脚本。
但这个方案的限制也很明显。第一,arguments[1]里如果包含嵌套结构(比如 JSON 数组),拼成表单后服务端收到的不是标准 JSON,而是key[0]=xxx这种索引键名,后端若按 JSON 解析会报错。第二,页面跳转会丢失当前页面的状态,如果你在验证某个中间流程,跳转后就得重新定位元素。第三,有些前端框架(比如 Vue/React)用拦截器统一处理请求,纯 DOM 表单提交会被当成非 Ajax 请求,照样走到页面刷新逻辑,跟真实场景对不上。所以这个方案只适合请求体是扁平的key=value结构、且不需要保持页面状态的情况。
3. 更优雅的姿势:用 CDP 把参数注入浏览器网络层
3.1 CDP 的 Fetch 拦截域:让浏览器“自己发”POST
如果你用的浏览器是 Chrome 或 Edge,Selenium 4 以上版本支持通过driver.execute_cdp_cmd()调用 Chrome DevTools Protocol(CDP)。这里有个我以前没注意到的细节:CDP 里有一个Fetch域,它允许你拦截、修改甚至伪造网络请求。这意味着你可以真正让浏览器网络栈发出一个 POST,而不是用 DOM 模拟提交。
from selenium import webdriver import base64 import json driver = webdriver.Chrome() # 启用 Fetch 拦截,只拦特定 URL,避免干扰页面其他请求 driver.execute_cdp_cmd("Fetch.enable", { "patterns": [{"urlPattern": "*://api.example.com/*", "requestStage": "Request"}] }) def send_fetchable_post(driver, url, payload: dict): headers = [ {"name": "Content-Type", "value": "application/json"}, {"name": "Accept", "value": "application/json"} ] body = base64.b64encode(json.dumps(payload).encode("utf-8")).decode("utf-8") driver.execute_cdp_cmd("Fetch.fulfillRequest", { "requestId": "manual-post-" + str(int(time.time())), "responseCode": 200, "responseHeaders": headers, "body": body })这段代码表面上是在“伪造响应”,但如果结合Fetch.failRequest或Fetch.continueRequest来配合页面里的 fetch 调用,效果就完全不同。实际我在项目里用到的模式是先触发页面里某个事件,让它调用fetch('/api/save', {method: 'POST', body: ...}),然后用Fetch.enable拦截到这个请求,读取request.postData,再根据参数模板替换成新的 POST 体放行。
有点玄学的是,execute_cdp_cmd的返回值和 Python 字典一一对应,参数名必须严格按协议走,大小写错了就悄悄报错。我第一次写requestStage时写成了RequestStage,结果拦截根本不生效,直到在控制台手动调试才发现。
3.2 Selenium 4 的 BiDi 支持:未来方向,目前能用的还不多
WebDriver BiDi 是 WebDriver 协议的下一代演进,目标是替代一部分 CDP 的底层能力,比如网络拦截、日志监听。Selenium 4.6+ 里暴露了driver.script和driver.network的相关接口,但截至目前,网络层面的操作方法还比较有限,不像 CDP 那么全面。
# 目前稳定可用的 BiDi 能力偏重脚本执行和日志捕获 from selenium import webdriver driver = webdriver.Chrome() # 监听控制台日志,顺带观察 fetch 是否报错 driver.script.add_console_message_handler(lambda msg: print("console:", msg)) driver.get("https://example.com")这段代码展示的是 BiDi 里比较成熟的一部分。作为一线开发者,我的建议是:如果你的测试脚本要跑在多种浏览器上,就别依赖 BiDi 做网络拦截,兼容性还不够稳。但如果你只在 Chrome 里跑,直接用 CDP 就行,BiDi 可以作为辅助手段监听页面日志,帮助定位 POST 请求是否被前端拦截。
4. 封装你自己的 Post 工具类:从参数池到一键提交
4.1 设计一个 PostContext:把参数和浏览器状态解耦
在写代码之前,先想清楚封装的目标:让测试用例只描述“要提交什么”,不关心“怎么提交”。我通常在项目里抽象出一个PostContext类,它负责维护参数池、记录请求历史、以及和 Selenium WebDriver 实例进行交互。这样做的好处是,当页面结构变了或者后端接口改了,只需要改一个地方,不用每个用例都动。
class PostContext: def __init__(self, driver): self.driver = driver self._params = {} self._history = [] def set_param(self, key, value): self._params[key] = value return self def set_params(self, data: dict): self._params.update(data) return self def clear(self): self._params.clear() return self def submit(self, url: str, method: str = "POST"): # 这里内部决定用 CDP 还是隐藏表单,对调用方透明 if self._use_cdp: self._submit_via_cdp(url) else: self._submit_via_hidden_form(url) self._history.append({"url": url, "params": self._params.copy()}) def recent_requests(self): return self._history参数说明:_params是当前参数池,支持链式调用;_history记录每次提交的参数快照,方便断言和排查;_use_cdp是内部开关,可以在初始化时根据浏览器类型自动判断。这个设计把参数管理从提交机制里剥离出来,调用方完全感知不到内部用了什么技术。
4.2 接入请求拦截:拿到真实发出的 POST 体
只有提交还不够,大部分测试场景还要验证前端拿到的响应是否正确。我在项目里加了一个回调机制:每次 POST 发出后,用 CDP 的Network.responseReceived事件捕获响应体,自动存到PostContext里,测试用例事后直接取用。
driver.execute_cdp_cmd("Network.enable", {}) def handle_response(params): if "response" in params: mime = params.get("response", {}).get("mimeType", "") if "json" in mime: request_id = params["requestId"] # 继续获取响应体,这里需要二次调用 CDP body = driver.execute_cdp_cmd("Network.getResponseBody", { "requestId": request_id }) print(body.get("body")) driver.network.add_network_response_handler(handle_response)这段代码用的是 Selenium 4 的network.add_network_response_handler,底层其实封装了 CDP 监听。逻辑很简单:每当浏览器收到响应,就检查是否为 JSON 类型,是的话把响应体打印出来。学究一点的说明:Network.getResponseBody只能在请求完成后调用,如果你在responseReceived事件里拿不到 body,那就再监听loadingFinished事件,在之后调用。踩过一次这个坑后,我就把响应体捕获统一放到了loadingFinished里,避免时序问题。
5. 落地到 UI 自动化场景:登录 Token 注入与数据工厂
5.1 用 Post 方式预置登录态,替代每次手动扫码
做 UI 自动化的人最烦什么?反复登录。尤其是有验证码或者二次认证的系统,每次跑用例前都要人工介入。我后来在框架里加了一个“登录态预置”模块:先用requests直接调登录接口拿到 token,再用 CDP 往浏览器的 LocalStorage 里塞 token,最后刷新页面。
import requests from selenium import webdriver def preset_login(driver, login_url, auth_payload): # 第一步:直接走接口拿 token resp = requests.post(login_url, json=auth_payload, timeout=10) token = resp.json().get("token") # 第二步:在浏览器上下文里写入 token driver.get("https://admin.example.com/login") driver.execute_script(""" localStorage.setItem('auth_token', arguments[0]); localStorage.setItem('refresh_time', Date.now()); """, token) driver.refresh() return token这样写的好处是,登录一次之后,后续所有用例都在已登录状态下执行,测试时间大幅缩短。参数说明:login_url是接口地址,auth_payload是账号密码字典,接口返回的 token 作为脚本参数传给 JavaScript,写入当前域名的 LocalStorage。注意driver.get()必须先访问一次目标域名,否则 LocalStorage 不存在,写入会静默失败。
5.2 数据工厂模式:按需生成不同格式的 POST 参数
在测试后台管理系统时,我经常需要构造不同来源的订单 POST 数据:来自 App 的、来自 H5 的、来自小程序端的,它们的请求体结构略有差异。如果每个用例手动写字典,代码会非常冗余。我设计了一个简单的数据工厂,通过类型标识自动生成参数模板。
class OrderDataFactory: @staticmethod def create(order_type: str, **override): base = { "source": order_type, "items": [], "address": {}, "remark": "" } if order_type == "app": base["items"] = [{"sku": "A100", "qty": 1, "price": 99.0}] base["address"] = {"name": "某用户", "phone": "13800000000"} elif order_type == "h5": base["items"] = [{"sku": "B200", "qty": 2, "price": 39.9}] base["address"] = {"name": "某用户", "phone": "13900000000"} elif order_type == "mp": base["source"] = "wechat-mini-program" base["items"] = [{"sku": "C300", "qty": 1, "price": 129.0}] base.update(override) return base这个工厂模式很直白:order_type决定基础数据结构,override允许测试用例覆盖任意字段。配合PostContext.set_params()使用,一行就能完成“构造参数并提交”。我曾经写过一个极端测试,用循环跑了一百多种参数组合,全部走这个工厂,代码不到三百行。
5.3 参数文件化:把测试数据移到 YAML 里,改需求不动代码
当测试用例越写越多,把参数直接写在 Python 里的维护成本会上升。我后来习惯把常用参数模板放到 YAML 文件里,代码里用一个加载器读取。
# post_params.yaml login: url: "https://admin.example.com/api/login" payload: username: "tester" password: "123456" order_app: url: "https://admin.example.com/api/orders" payload: source: "app" items: - sku: "A100" qty: 1对应加载代码用yaml.safe_load()读取后,直接转换成 Python 字典传给工厂或PostContext。这种做法在团队协作时特别有效,测试同学不用懂代码也能改数据。
6. 常见问题与排查:我在封装 POST 过程中遇到的五个坎
6.1 现象:隐藏表单提交后页面白屏,控制台报405 Method Not Allowed
原因:我一开始把表单的action指向了后端接口地址,但该接口只接受application/json的 POST,不接受application/x-www-form-urlencoded。服务器没找到处理这个 content-type 的路由,直接返回 405。
解决:改用 CDP 的Fetch.continueRequest配合手动构造请求头 + JSON body,让请求体和接口定义保持一致。从那以后我就把隐藏表单方案的适用范围限定在只测试旧系统的情况里。
6.2 现象:CDP 拦截不生效,请求全部放行
原因:Fetch.enable的patterns参数里urlPattern写成了*://example.com/*,但没有加协议匹配细节,导致部分子域名请求没被匹配到。
解决:把urlPattern改成*://*.example.com/*,并在测试启动时打印一条日志确认拦截器已注册。我总结的经验是,Fetch.enable每个 pattern 的urlPattern要尽量精确,用在线正则工具验证后再复制进代码。
6.3 现象:execute_cdp_cmd报错Invalid parameters
原因:CDP 的Fetch.enable要求patterns里每一项都必须有urlPattern字段,但我传了一个空字典进去,协议解析失败。
解决:检查传参是否完整,必要时把请求参数直接写成 JSON 字符串而不是 Python 字典,用json.dumps()转换一遍再传给execute_cdp_cmd,能规避一部分类型问题。
6.4 现象:接口返回 200,但页面数据没刷新
原因:前端用了fetch但成功后没有重新渲染列表数据,而我的断言直接查了 DOM 中旧数据。
解决:提交成功后加一个显式等待,轮询页面元素直到出现新数据,或者是加一个短time.sleep()。虽然用 sleep 不太优雅,但在 UI 自动化里它比复杂的期望条件更省事。我一般优先用WebDriverWait配合expected_conditions,实在不行才用 sleep。
6.5 现象:火狐浏览器里execute_script返回的是 Promise 对象
原因:Selenium 在 Firefox 里对某些异步脚本的处理和 Chrome 不同,当 JavaScript 代码里有return fetch(...)这类 Promise 语句时,Python 端拿到的是 Promise 的占位对象而不是执行结果。
解决:给脚本外层套一个async function,并在末尾调用return await ...,让 Selenium 等待 Promise 解析完成再返回。如果还是不行,就退回用同步 XHR。
var result = await new Promise(function(resolve, reject) { var xhr = new XMLHttpRequest(); xhr.open('POST', arguments[0], true); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.onload = function() { resolve(xhr.responseText); }; xhr.onerror = function() { reject(new Error('xhr error')); }; xhr.send(JSON.stringify(arguments[1])); }); return result;这段代码放在driver.execute_async_script()里使用,arguments自带一个回调函数参数,需要手动调用它来传结果。参数说明:arguments[0]是 URL,arguments[1]是请求体对象,xhr.responseText会传给 Selenium 端。
7. 二次开发:把你的框架升级成支持请求级追踪和断言的半成品平台
如果你已经跑通了基础的 POST 封装,下一步值得做的是给每次请求加一个request_id,让日志、断言和报告能对应起来。我实现的方式是在PostContext里维护一个自增计数器,每次提交时生成request_id,同时用 CDP 的Network.requestWillBeSent事件把真实网络请求和这个request_id关联。
class TrackedPostContext(PostContext): def __init__(self, driver): super().__init__(driver) self._seq = 0 driver.network.add_request_handler(self._hook) def _hook(self, params): # 从请求 URL 里找到我们的标记参数,然后回填 request_id url = params.get("request", {}).get("url", "") if "x-test-rid" in url: # 解析 rid 并存入当前上下文 rid = url.split("x-test-rid=")[1].split("&")[0] self._history[-1]["request_id"] = rid def submit(self, url, **kwargs): self._seq += 1 rid = f"RID-{self._seq:06d}" sep = "&" if "?" in url else "?" url = f"{url}{sep}x-test-rid={rid}" super().submit(url, **kwargs) return rid这个设计的价值在于,当测试报告里出现失败时,你可以直接拿着request_id去后端日志系统里查这次请求的全部链路,不用再靠浏览器开发者工具手动翻。加上响应体捕获之后,还能做响应断言——比如校验返回的code是否为0,而不只是看页面有没有报错。这就把 UI 自动化和接口断言缝合进同一个框架了。
配合 CI 使用时,我会在 pytest 的 hook 里为每个用例创建一个独立的PostContext,用 fixture 实例化并注入测试函数。这样每个用例的参数互不污染,跑完自动清理,跟 pytest-xdist 的并行执行也能兼容。如果你后续要接 Allure 报告,可以在断言失败时把request_id和响应体附加到 allure 的 attachment 节点上,排查问题的时间能缩短一大截。
最后说一个我的习惯:每次封装完这类工具,我都会强制自己写一份“五分钟复现”文档——在干净的机器上把环境装好,跑通一个最小用例再回来看代码。这样能逼自己把依赖写清楚,不至于过了两周自己都忘了execute_cdp_cmd的某个参数是怎么传的。希望这份笔记能给你一个可复用的起点,少踩几个我踩过的坑。
本文还有配套的精品资源,点击获取