凌晨两点,预发布环境的回归测试又挂了。这次不是服务端报 500,也不是页面白屏,而是一个诡异的问题:用户新增的评论,刷新后就消失了。接口返回 200,前端 toast 也弹了"保存成功",但一刷新列表,什么都没留下。对着浏览器 DevTools 的 Network 面板反复看了好几遍,才发现响应里的 id 字段是空字符串,前端乐观更新拿临时 ID 顶了上去,根本没存进 IndexedDB。那一刻我就决定:必须让自动化测试替我盯住这类"UI 成功、数据落空"的阴间 bug。
这篇文章就复盘我是怎么用 Playwright 拦截网络请求,并且自动断言响应数据真正写进了前端持久化存储(IndexedDB、localStorage 等)的。中间踩的坑会一一说清,包括官方文档没告诉你的那点事。
问题拆解
场景:现代前端重度依赖本地存储(IndexedDB、localStorage、Redux Persist 等)做离线优先或性能优化,很多写操作是"先更新 UI,再异步同步到后端"。这种做法下,测试如果只看 DOM 是不是出现了"成功"字样,完全可能被前端乐观更新骗过去------接口其实返回了错误数据,甚至根本没成功落库。
根因:常规 E2E 测试(不管用 Selenium、Cypress 还是 Playwright 的初级用法)只会做两件事:操作页面,然后断言可见元素的内容。你不主动去打开"网络黑盒",也不去翻"存储黑盒",就永远不知道接口响应和本地数据是不是对得上。
为什么常规方案不行
- 即便你用 DevTools 手动验证过一次,下次发版还可能复现,因为前端代码里总会有人在
then里漏掉错误处理。 - 你不可能每个回归周期都让 QA 手动清空 IndexedDB、抓包对照 JSON------这成本太高,人也容易犯错。
所以必须把"拦截请求 → 捕获响应 → 读取存储 → 断言一致性"这一套全自动化。而 Playwright 的原生网络拦截能力和 page.evaluate 正好能完美配合。
方案设计
技术选型 :Playwright(Python 版,1.40+),搭配内置断言库 playwright.async_api.expect。
核心思路:
- 用
page.route拦截目标 API,调用route.fetch()真实发送请求并拿到完整响应体。 - 将响应数据暂存到一个 Python 变量里。
- 页面操作完成后,通过
page.evaluate读取 IndexedDB / localStorage 的对应数据。 - 用
assert比较两个数据集的关键字段(id、状态、金额等)。
为什么不选 Cypress / Selenium?
- Cypress 有
intercept也能捕获响应,但它的存储读取还得通过插件或自定义命令,对于 IndexedDB 这种异步 API 的支持不够利索,而且只能跑在 Chromium 系。 - Selenium 的网络拦截是残废的,需要额外搭代理(Browsermob 之类),太重。
- Playwright 的
route.fetch是 1.29 版本新加的,能让你直接拿到真实响应对象(包含状态码、body),不需要手动构造fulfill,这让我们能原封不动地把真实后端响应抓出来,测的就是线上真实逻辑。
核心实现
先看一个最通用的工具函数:拦截请求并返回解析后的 JSON 响应。这个函数解决"怎么从路由里安全掏出真实响应体"的问题。
python
import asyncio
import json
from typing import Optional
from playwright.async_api import async_playwright, Route, Request
async def intercept_response(route: Route) -> Optional[dict]:
"""
在 route 内部 fetch 真实请求,并尝试解析 JSON。
若成功返回 dict,否则返回 None。
"""
# 关键:先放行请求,拿到真实响应对象
response = await route.fetch()
# 必须消费 body,否则后续 page 可能拿不到
body = await response.body()
try:
return json.loads(body)
except Exception:
return None
finally:
# 用真实的响应数据回填页面,否则页面会一直等
await route.fulfill(response=response)
注释 :为什么不用 route.continue_() 后再去监听 page.on('response')?因为那样你得维护一个全局字典把 request 和 response 对应起来,代码一复杂就乱。route.fetch() 是 Playwright 1.29 之后出的,它能直接在当前拦截点获取响应,最适合这种需求。
接下来,我们需要一个能从 IndexedDB 里安全取出数据 的函数。直接在 page.evaluate 里用原生 IndexedDB API,并且必须处理它那套奇葩的"事务关闭后数据才可读"的异步问题。
python
async def get_indexeddb_data(page, db_name: str, store_name: str) -> list:
"""
通过 page.evaluate 读取指定 IndexedDB 中某个 object store 的全部数据。
返回一个 Python list。
"""
js_code = """
async (dbName, storeName) => {
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName);
request.onsuccess = (event) => {
const db = event.target.result;
try {
const transaction = db.transaction(storeName, 'readonly');
const store = transaction.objectStore(storeName);
const getAll = store.getAll();
getAll.onsuccess = () => resolve(getAll.result);
getAll.onerror = (e) => reject(e.target.error);
} catch (err) {
reject(err);
}
};
request.onerror = (e) => reject(e.target.error);
});
}
"""
return await page.evaluate(js_code, db_name, store_name)
这个函数解决了什么问题:很多教程教你用 indexedDB.open 后直接操作,但忘了 IndexedDB 的请求是异步的,必须 new Promise 包起来,否则 evaluate 根本拿不到结果,只会返回 undefined。
第三个代码块:把拦截、读取、断言组合成一个完整的测试用例 。假设我们测试一个记账 App,新增一笔支出后,需要验证后端返回的 id 和 amount 确实写入了本地的 expenses 仓库。
python
async def test_expense_persistence():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
captured_response = None
async def handle_route(route: Route):
nonlocal captured_response
# 只拦截我们关心的 API
if "/api/expenses" in route.request.url and route.request.method == "POST":
captured_response = await intercept_response(route)
else:
await route.continue_()
await page.route("**/*", handle_route)
# 模拟用户操作:填写表单并提交
await page.goto("https://moneyapp.example.com/add")
await page.fill('input[name="amount"]', "42.0")
await page.click('button[type="submit"]')
# 等待数据写入 IndexedDB(一般会有一个状态触发,可以等待某个隐藏标志)
await page.wait_for_selector(".toast-success", timeout=5000)
# 再等一下异步落盘
await asyncio.sleep(1)
# 保证拦截器确实抓到了响应
assert captured_response is not None, "未捕获到 API 响应"
resp_id = captured_response.get("id")
resp_amount = captured_response.get("amount")
# 读取 IndexedDB
stored_records = await get_indexeddb_data(page, "MoneyDB", "expenses")
# 断言:至少存在一条记录,id 与 amount 完全匹配
matched = any(
r.get("id") == resp_id and r.get("amount") == resp_amount
for r in stored_records
)
assert matched, f"持久化数据不匹配: 响应={captured_response}, 存储={stored_records}"
await browser.close()
设计意图 :用 nonlocal 保存响应数据,避免全局变量污染;用 wait_for_selector 确保 UI 反馈后再等 1 秒,留给 IndexedDB 事务提交(这是实践总结出的稳妥延迟)。如果你不 asyncio.sleep,直接读 IndexedDB 只会读到旧数据或空值。
踩坑记录
坑一:route.fetch() 后页面卡死
现象 :加了 route.fetch() 后,页面一直在加载,后续操作超时。
原因 :调用 route.fetch() 后如果不调用 route.fulfill(response=response),页面拿不到响应体,就会一直挂着。
解决 :必须 await route.fulfill(response=response),把真实响应原封不动回填。官方文档只说了 route.fetch 可以获取响应,没强调这个回填动作是强制的,否则页面直接假死。
坑二:IndexedDB 数据"读不到"
现象 :get_indexeddb_data 返回空数组,但手动在 DevTools 里能看到数据。
原因 :IndexedDB 的事务在浏览器中是微任务队列处理的,evaluate 里的 indexedDB.open 成功拿到 DB 对象后,你立刻执行 transaction.objectStore.getAll,如果前面同一页面上下文中还有未提交的写事务,读取事务可能被阻塞或读到旧快照。
解决 :最稳的办法是在写入操作(点击保存)后,等待一个确定的后置标记(比如列表中出现的某个 DOM 节点,该节点就依赖于 IndexedDB 数据渲染),而不是靠死等 sleep。必要时用 page.wait_for_function 检测存储数据是否已存在。例如:
python
await page.wait_for_function(
"""async () => {
const db = await new Promise(r => { const req = indexedDB.open('MoneyDB'); req.onsuccess = e => r(e.target.result); });
const tx = db.transaction('expenses', 'readonly');
const store = tx.objectStore('expenses');
const all = await new Promise(r => { const g = store.getAll(); g.onsuccess = () => r(g.result); });
return all.length > 0;
}""",
timeout=5000
)
这样就避免了盲目 sleep,也更严谨。
效果验证
我用这套方案重构了我们项目的回归测试套件。原来只检查 DOM 文本的用例跑了 120 条,其中 8 条在"UI 正常但持久化失败"时依然绿灯通过。加上拦截+存储校验后,新增的 15 条针对性用例在一次灰度中直接揪出两个 bug:
- 一个因后台字段改名导致
id写入 IndexedDB 为空串; - 一个因 service worker 缓存策略不当,导致离线模式下存了旧数据。
对比表:
| 指标 | 纯 UI 断言 | 网络拦截 + 存储断言 |
|---|---|---|
| 离线/异步落库 bug 漏出率 | 25%(8/32) | 0% |
| 发现后端字段变更影响 | 迟于线上报错 | 预发布阶段即捕获 |
| 单用例执行时间 | 3.2s | 4.5s(可以接受) |
可直接用的代码/工具
我把上面的逻辑抽成了一个可复用的 Playwright fixture(Pytest 风格),你只需在 conftest.py 里加入以下命令就能直接用:
python
# 粘贴到你的 conftest.py,之后在测试中用 page.route 配合 Interceptor 类即可
from my_utils import async_intercept_and_store, get_indexeddb_data
或者直接在项目里安装我打包好的工具包:
bash
pip install playwright-db-validator # 示例,实际上 GitHub 上我会发布
然后一行断言:
python
await expect_persistence(page, api_url_pattern, db_name="MyDB", store_name="records")
完整的包我会持续更新到 GitHub,见下方链接。
#Playwright #前端测试 #自动化测试 #数据一致性 #IndexedDB #Python
关于作者
我是 baoful,一个专注后端与前端测试基建的实战派架构师,常年跟各种"UI 没问题但数据就是不对"的 Bug 死磕。
GitHub: github.com/baofugege --- 上面放了更多 Playwright 拦截与存储验证的完整示例。
Sponsor: github.com/sponsors/ba... --- 如果这篇文章帮你省下排查 hours,欢迎请我喝杯咖啡。
提供服务:Python 后端性能优化 / 测试工具定制 / 技术咨询,联系 Telegram @baofugege