简介:面向需要掌握Selenium模拟POST请求的自动化测试工程师与爬虫开发人员,这份rar压缩包提供了一种通过WebDriver封装post参数、并获取异步JSON返回值的可行方案,尤其适用于页面内异步接口验证或需要携带浏览器状态的场景。压缩包共3个文件,以txt说明文档、bak备份文件及java源文件为主,整体仅5KB,结构精简便于快速对照,其中java文件展示了核心调用逻辑,txt文档则补充了实现思路与注意事项。已有3699人浏览学习,说明该示例在Selenium二次封装与post请求模拟方面具有一定代表性。内容涵盖fetch请求构造、数据序列化、execute_async_script异步回调处理、响应解析等要点,并给出可运行的java代码,便于读者把这一封装方式迁移到自己的测试框架或数据采集脚本中。
1. 先说结论:selenium 不是只能点按钮,post 提交也值得封装进去
做界面自动化的人大多有个惯性:selenium 管点击、管输入、管断言,而 requests 管接口请求,两个工具链各管一摊,登录态、token、cookie 在两边各存一份,跑用例时经常两边对不上。我早先也这么干,直到有一次要批量造测试数据,每个用例都得先调一个 post 接口再把结果反射到页面上,两边手工搬运 cookie 搬了整整一下午,才下决心把 post 参数提交统一封装进 selenium 框架里。这套项目的核心思路很简单:给 post 提交开两条通道,一条在浏览器上下文里用 fetch 发请求,自动带上浏览器 cookie,不刷新页面;另一条把 selenium 里的 cookie 桥接给 requests 的 Session,在测试代码里直接调接口。适合做 UI 自动化、数据准备、页面与接口联调的人直接拿去改。
2. 项目定位与技术选型:为什么需要两条 post 通道
2.1 这个封装解决的真实问题
先说场景。很多管理后台的前端是 ajax 提交,点保存按钮后浏览器发一个 post 请求,参数由前端 JS 动态拼出来。selenium 能做的是点按钮,但按钮点了之后请求什么时候发、参数对不对、失败后有什么反馈,全靠页面表现去猜。把 post 提交直接封装进来之后,你可以绕过按钮,按自己的参数构造请求,再回到页面断言列表或者弹窗变化,测试的可控性完全不一样。
另一个场景是登录态复用。selenium 登录一个系统后,浏览器里存了一整套 cookie 和 LocalStorage,而 requests 这边是一个全新的 Session。很多人直接拿 requests 发接口,撞上 401 就懵了,其实不是接口写错了,是两边会话没打通。把 cookie 做一个桥接方法,requests 就能像浏览器一样带着身份访问接口,登录一次,UI 和接口都能用。
这两类问题凑在一起,就有了一个明确的设计目标:post 请求在框架里应该有统一入口,调用方不关心请求是从浏览器里发的还是从 requests 里发的,只传 URL 和参数,返回 JSON 结果。
2.2 技术选型:selenium 4 加 requests,外加一个异步执行通道
技术栈上我选了 Selenium 4 和 requests 两个基础库,再加 WebDriver Manager 自动管理浏览器驱动。Selenium 4 相对旧版更稳,定位元素用相对定位也方便,驱动不用手工下载,交给 WebDriver Manager 在首次启动时自动匹配本机浏览器版本,CI 环境里部署省事很多。
requests 这边没用什么额外封装,Session 对象天然支持 cookie 持久化、连接复用、超时控制,做接口调用的底子足够。很多人会在这时候引入一个大而全的 HTTP 客户端库,但这个小项目里 requests 的 Session 配合超时和重试已经完全够用,多依赖一个库反而增加在 CI 机器上的安装成本。
真正的关键是第三条通道:用 driver.execute_async_script 在浏览器上下文里执行 fetch。这一招的好处是请求从页面自己的环境发出去,自动带 cookie、自动带同源身份,且不会触发页面跳转。为什么用 async 脚本而不是同步的 execute_script,后面会单独讲,这里先记住结论:fetch 返回的是 Promise,同步脚本接不住异步结果,必须用异步回调把数据送回来。
2.3 目录结构:一个模块一个职责
整个封装我按职责拆成了六个文件,每个文件只做一件事。
| 文件路径 | 职责 | 关键类 |
|---|---|---|
| core/driver_factory.py | 创建、退出浏览器驱动 | DriverFactory |
| core/post_client.py | post 请求的统一封装,双通道入口 | PostClient |
| core/payload_builder.py | 参数模板与动态变量替换 | PayloadBuilder |
| core/session_bridge.py | selenium cookie 导出到 requests | export_cookies |
| core/exceptions.py | 自定义异常类型 | PostSubmitError |
| cases/test_user_flow.py | 完整链路示例用例 | test_user_flow |
这样的拆法在二次开发时有好处:改 post 的发送逻辑只动 post_client.py,改参数生成只动 payload_builder.py,互不牵扯。对新手来说,照着这个结构往里填业务代码比在一个大文件里到处找逻辑要友好得多。
3. 核心模块拆解:从驱动管理到 post 客户端
3.1 驱动工厂:让每个用例拿到的浏览器都是可预测的
浏览器驱动看起来简单,实际是翻车重灾区。不配参数,CI 上跑起来各种诡异问题,窗口弹不出来、GPU 报错、沙箱权限不够。DriverFactory 把这些参数统一收口,保证每个用例拿到的浏览器行为一致。
# core/driver_factory.py from selenium import webdriver from selenium.webdriver.chrome.options import Options from webdriver_manager.chrome import ChromeDriverManager from selenium.webdriver.chrome.service import Service class DriverFactory: @staticmethod def create_driver(headless: bool = True, lang: str = "zh-CN") -> webdriver.Chrome: options = Options() if headless: # Selenium 4 的 headless 模式,直接传 new 即可 options.add_argument("--headless=new") options.add_argument(f"--lang={lang}") options.add_argument("--disable-gpu") options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage") # 关闭"正在受到自动软件控制"的提示条 options.add_experimental_option("excludeSwitches", ["enable-automation"]) service = Service(ChromeDriverManager().install()) driver = webdriver.Chrome(options=options, service=service) driver.set_page_load_timeout(20) driver.set_script_timeout(10) return driver @staticmethod def quit_driver(driver: webdriver.Chrome) -> None: if driver: driver.quit()这段代码里值得拎出来说的是三个参数。--disable-dev-shm-usage是 CI 容器里经常翻车的原因,容器默认 /dev/shm 只有 64MB,浏览器一跑就崩,加上这个参数让浏览器改用 /tmp 做共享内存。--no-sandbox在 root 用户下必须加,否则 Chrome 直接拒绝启动。set_script_timeout(10)是给后面 execute_async_script 用的,fetch 请求超过 10 秒会报脚本超时,这个值按你接口的响应速度调。
新手容易漏的是quit_driver没有在用例结束的 finally 块里调用,导致浏览器进程一直占着端口。后面避坑章我还会再提一次,这里先记住:创建驱动和销毁驱动要成对写。
3.2 PostClient:把 post 提交收敛成一个类
这是整个套件的核心。PostClient 对外只暴露两个方法,request_via_requests走 requests 通道,request_via_fetch走浏览器通道。调用方不用关心 cookie 怎么带、请求怎么发,只传 URL、参数体和必要信息。
# core/post_client.py import json import time import logging import requests class PostClient: def __init__(self, driver, session=None, retry=2, timeout=10): self.driver = driver self.session = session or requests.Session() self.retry = retry self.timeout = timeout self.logger = logging.getLogger("post_client") def request_via_requests(self, url: str, payload: dict) -> dict: """通道A:requests 直接发 post,需要先桥接 cookie""" for attempt in range(self.retry + 1): try: resp = self.session.post( url, json=payload, timeout=self.timeout, ) resp.raise_for_status() return resp.json() except Exception as e: self.logger.warning("requests post 失败,第 %s 次: %s", attempt + 1, e) if attempt == self.retry: raise time.sleep(1) def request_via_fetch(self, url: str, payload: dict, extra_headers: dict = None) -> dict: """通道B:在浏览器上下文执行 fetch,自动带浏览器同源 cookie""" script = """ const params = arguments[0]; const done = arguments[arguments.length - 1]; fetch(params.url, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(params.headers || {}) }, body: JSON.stringify(params.body) }) .then(r => r.json().then(data => done({status: r.status, data: data}))) .catch(err => done({error: err.message})); """ result = self.driver.execute_async_script(script, { "url": url, "body": payload, "headers": extra_headers or {}, }) if "error" in result: raise RuntimeError(f"浏览器内 post 失败: {result['error']}") return result["data"]参数说明:retry默认 2,意思是总共尝试 3 次,第 3 次失败才抛出异常,适用于接口偶发超时的场景;timeout单位是秒,传给 requests 的连接超时读超时;extra_headers用来覆盖或补充请求头,比如某些接口需要自定义X-Requested-With。
request_via_fetch里有一行容易被忽略但极其重要的代码:done是 execute_async_script 传入的最后一个 argument,它本质上是一个回调函数,fetch 的 Promise 结果必须通过done(...)传回给 Python 侧,否则 Python 拿到的永远是 None。这也是为什么不能把这里的脚本丢给 execute_script 去跑,同步执行拿不到 Promise 里的内容,这是一个让人排查到怀疑人生的点。
3.3 PayloadBuilder:模板参数替换,告别硬编码
post 请求的参数大多数不是固定的,登录接口要 token、查询接口要时间戳、保存接口要随机流水号。如果每个用例里都手写参数拼接,改一处模板全得跟着改。我在这里做了一个非常轻量的模板替换机制,用${name}占位符加一个 context 上下文来解决。
# core/payload_builder.py import json class PayloadBuilder: def __init__(self, template: dict, context: dict = None): self.template = template self.context = context or {} def add_context(self, key: str, value) -> "PayloadBuilder": self.context[key] = value return self def build(self) -> dict: """把模板里 ${key} 替换成 context 中的值,并重新解析为 JSON""" raw = json.dumps(self.template, ensure_ascii=False) for key, value in self.context.items(): raw = raw.replace("${%s}" % key, str(value)) return json.loads(raw)逻辑很简单:先把 dict 序列化成 JSON 字符串,在字符串层面做占位符替换,再反序列化回 dict。好处是替换不区分值是嵌套还是数组,比如${items}替换成一个 JSON 数组字符串,replace 之后仍能被 json.loads 正确恢复。调用的时候链式把动态值加进 context,逻辑清晰。
我一般会配合页面提取方法来用。比如 CSRF token 是从页面 meta 标签里取的:
token = driver.execute_script( "return document.querySelector('meta[name=\\\"csrf-token\\\"]').content" ) payload = PayloadBuilder(PAYLOAD_TEMPLATE).add_context("token", token).build()这样参数模板文件可以长期保持稳定,真正变的只有 context 里那几个动态值,排查问题时一眼就能看清提交的到底是什么。
4. 会话桥接与完整链路:selenium 和 requests 怎么配合
4.1 cookie 桥:从 webdriver 到 requests.Session
跨工具链协作最大的鸿沟是会话。浏览器把登录态放在 cookie 里,requests 只认自己的 cookie jar。直接复制是可行的,但细节上有几个坑,最常见的问题是 requests 这边发请求时丢掉了 domain 信息,服务端根据域名校验时就判断为无效会话。
# core/session_bridge.py import requests def export_cookies(driver, session: requests.Session = None) -> requests.Session: """把 webdriver 当前会话的 cookie 完整复制给 requests.Session""" if session is None: session = requests.Session() driver_cookies = driver.get_cookies() for c in driver_cookies: session.cookies.set( c["name"], c["value"], domain=c.get("domain"), path=c.get("path", "/"), ) return session这段代码里 session.cookies.set 显式传入了 domain 和 path,这是很多人忽略的。requests 自带的重定向和会话管理都依赖这些属性,只传 name 和 value 虽然也能带上 cookie,但一旦接口发生重定向到别的路径,cookie 就送不出去了。另外注意 httpOnly 的 cookie 也能通过 get_cookies() 拿到,requests 不受这个约束,所以这里不用额外过滤。
桥接时机建议放在登录成功之后。登录接口返回的 Set-Cookie 只有浏览器真正写入后,get_cookies() 才能拿到,所以保险的做法是先让页面跳转到一个已登录的页面,再执行桥接。
4.2 浏览器内 fetch 提交:async 脚本与超时控制
浏览器内发请求这个思路,很多文章都提过,但几乎没人把 async 机制讲透。selenium 的 execute_script 是同步执行的,JavaScript 引擎执行完脚本体就返回,而 fetch 的回调要等网络响应,必然在脚本体执行完之后才触发,所以用 execute_script 跑 fetch 拿到的返回值永远是空。解决办法是换成 execute_async_script,selenium 会额外注入一个回调函数,脚本主动在异步完成后调用它。
# 单独看 fetch 通道的调用方式 driver.set_script_timeout(10) script = """ const params = arguments[0]; const done = arguments[arguments.length - 1]; fetch(params.url, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(params.body) }) .then(r => r.json().then(data => done({status: r.status, data: data}))) .catch(err => done({error: err.message})); """ result = driver.execute_async_script(script, { "url": "http://service.internal/api/save", "body": {"name": "某条测试数据"}, }) print(result)代码里 arguments 有两个:第一个是传入的参数字典,最后一个是 done 回调。脚本里的 Promise 链最终都会收敛到 done 这一条路上,成功传回{status, data},失败传回{error},Python 侧根据字段名判断是哪种结果。
这里有个决策点:浏览器内 fetch 拿到的响应如果依赖里层的 JSON 结构,必须在 JS 侧先r.json()解析完再回调,不能把 response 对象直接传回来,因为 selenium 的序列化只支持基础类型和普通对象,Response 对象传回来基本会变成空壳。还有一点,fetch 默认不会携带跨域凭证,如果目标接口和当前页面不同域,需要在 fetch 参数里加credentials: 'include'。
4.3 完整链路:登录、准备数据、界面验证一条龙
把上面的模块串起来,就是一个完整可运行的用例。下面这段示例是典型的业务流程:先用界面登录,再用 requests 调接口准备数据,最后回到界面验证数据是否展示。
# cases/test_user_flow.py from core.driver_factory import DriverFactory from core.post_client import PostClient from core.session_bridge import export_cookies from core.payload_builder import PayloadBuilder from selenium.webdriver.common.by import By TEMPLATE = { "title": "接口准备的数据-${ts}", "category_id": "${category_id}", } def test_user_flow(): driver = None try: driver = DriverFactory.create_driver(headless=True) driver.get("http://service.internal/login") # 省略界面登录操作,这里是 driver.find_element(...) # 1. 桥接浏览器会话到 requests session = export_cookies(driver) # 2. 用 requests 通道提交一条数据 client = PostClient(driver, session=session) payload = PayloadBuilder(TEMPLATE, { "ts": "20250601", "category_id": 12, }).build() client.request_via_requests("http://service.internal/api/create", payload) # 3. 刷新页面,验证新数据出现在列表第一行 driver.refresh() first_item = driver.find_element(By.CSS_SELECTOR, "#list tr:first-child td:first-child") assert "接口准备的数据" in first_item.text finally: DriverFactory.quit_driver(driver)用例的步骤读起来很顺:登录发生在浏览器里,cookie 被提取出来给 requests,接口调用和数据准备走的是高效率的 HTTP 通道,最终验证又回到真实浏览器。整个过程一个 driver 实例贯穿始终,不会出现两个工具各持一套会话的问题。
5. 避坑手册:五个高频问题与排查方法
5.1 这五个坑,每一个都让我排查过两小时以上
第一个坑:requests 通道发 post 始终 401。现象是浏览器里同样的请求没问题,转到 requests 就报未授权。原因是导出 cookie 时丢失了 domain 属性,requests 在发送时没有把 cookie 绑定到目标域上。解决方法是像 session_bridge 里那样显式传 domain,并且确认接口域名与登录域名一致,跨域时还要检查 SameSite 属性,必要时在 requests 里手动补 Host 头。
第二个坑:execute_script 执行 fetch 返回 None。现象是 Python 侧拿到的是 None,而浏览器开发者工具里请求已经发出去了。原因是 fetch 的 Promise 结果无法通过同步脚本回传。解决方法是改用 execute_async_script,利用注入的回调函数把结果带回来,同时确认设置了合理的 set_script_timeout,否则请求超过 10 秒就会被判超时中断。
第三个坑:浏览器内 fetch 被 CORS 拦截。现象是脚本抛 “Failed to fetch”,浏览器控制台显示跨域报错。原因是 fetch 发出的是跨域请求,目标接口没返回允许跨域的响应头。解决方法是优先把要请求的接口部署在同域下;如果做不到,可以先用 driver.get 访问目标域的一个静态页面,让当前页面和接口同源,再执行 fetch,这是我在实际中用过最省事的变通做法。
第四个坑:payload 里的 token 写死,换一天就跑不通。现象是同样的用例,上午能跑下午 403,重新登录后又好了。原因是不少管理后台的 CSRF token 是每次会话动态生成的,模板里写死必然过期。解决方法是每次构造 payload 前从页面 meta 或隐藏 input 里动态提取 token,注入到 PayloadBuilder 的 context 中,替代硬编码。
第五个坑:用例跑完浏览器进程不退出,CI 环境端口被占满。现象是 job 跑几次后 chromedriver 启动失败,报端口被占用。原因是用例异常时 driver.quit() 没有执行,浏览器进程成了僵尸进程。解决方法是所有创建了 driver 的用例,把 driver 操作全部放进 try/finally,finally 里无条件执行 quit_driver,不依赖断言结果。
6. 二次开发:把它改造成数据驱动和可回放的请求模块
6.1 数据驱动化:从 yaml 读取请求用例
PostClient 封装完成之后,下一步自然是想办法把请求配置从代码里挪出去。我的做法是引入一个非常薄的 yaml 加载层,每个文件描述一类接口的请求模板,测试代码里读取模板、覆盖字段、发送请求。
# config/api_book.yaml create_item: url: "http://service.internal/api/create" method: post retry: 1 template: title: "新建数据-${ts}" category_id: "${category_id}"调用侧只用三行代码:
import yaml from core.post_client import PostClient api_spec = yaml.safe_load(open("config/api_book.yaml"))["create_item"] payload = PayloadBuilder(api_spec["template"], context).build() result = client.request_via_requests(api_spec["url"], payload)这样的改造价值在于,新加一个接口不需要动 Python 代码,只要加一段 yaml 配置。团队里不懂代码的测试同学也能自己维护接口用例。
6.2 异常现场保留:失败时自动落盘
接口类自动化最难排查的是偶现失败。我建议在 PostClient 里补一个异常钩子,请求失败时把 cookie、payload、响应文本、当前页面截图全部落盘,形成一个可复现的现场包。
def _dump_failure(self, url, payload, error, page_path: str) -> None: with open(f"{page_path}/payload.json", "w", encoding="utf-8") as f: json.dump({"url": url, "payload": payload}, f, ensure_ascii=False, indent=2) with open(f"{page_path}/error.txt", "w", encoding="utf-8") as f: f.write(str(error)) if self.driver: self.driver.save_screenshot(f"{page_path}/page.png") with open(f"{page_path}/cookies.json", "w", encoding="utf-8") as f: json.dump(self.driver.get_cookies(), f, ensure_ascii=False, indent=2)这个现场包在排查时价值极高。payload.json 能看出参数替换是否生效,cookies.json 能判断会话是否过期,page.png 能还原当时的页面状态。从那以后,我每次封装请求类都强制自己把“失败可追溯”作为默认能力写进去,而不是等出了问题再补,这个习惯帮我省了很多次远程连服务器翻日志的时间。希望帮到你。
本文还有配套的精品资源,点击获取