Playwright 网络拦截踩坑实录:我花了 3 小时才搞懂数据持久化验证的正确姿势

凌晨两点,预发布环境的回归测试又挂了。这次不是服务端报 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

核心思路

  1. page.route 拦截目标 API,调用 route.fetch() 真实发送请求并拿到完整响应体。
  2. 将响应数据暂存到一个 Python 变量里。
  3. 页面操作完成后,通过 page.evaluate 读取 IndexedDB / localStorage 的对应数据。
  4. 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,新增一笔支出后,需要验证后端返回的 idamount 确实写入了本地的 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

相关推荐
weedsfly1 小时前
React 开发中的闭包陷阱:四个真实场景,让你彻底理解闭包
前端·react.js
MariaH1 小时前
Git Cherry Pick 常用操作
前端
初圣魔门首席弟子1 小时前
AI Agent 核心原理:工具调用(Function Calling)完整工作流程详解
前端·数据库·人工智能
CodeSheep1 小时前
又是梁文锋,有点猛啊。
前端·后端·程序员
陈老老老板1 小时前
如何用 Bright Data Web Scraper API + Coze 搭建 Reddit 行业情报聚合 Bot(2026 实战指南)
前端·人工智能
恋猫de小郭1 小时前
由于 iOS 26 的键盘变化,Flutter 又要重构键盘区域逻辑
android·前端·flutter
怕浪猫2 小时前
Electron 开发实战(十五):实战项目|从零搭建桌面即时通讯(IM)应用
前端·javascript·electron
喜欢踢足球的老罗2 小时前
破解 Chrome 扩展的「两世界难题」:MV3 下的 ISOLATED 与 MAIN World 桥接之道
前端·chrome