It was 2 AM. The regression suite in the staging environment failed again. Not a server error, not a blank page—something far sneakier: new comments that vanished after a refresh. The API returned a 200 status. The frontend even popped a cheerful “Saved successfully” toast. Yet after refreshing the list, there was nothing. I stared at the DevTools Network panel until my eyes hurt, and then I saw it: the id field in the response was an empty string. The optimistic update had swapped in a temporary ID, and the data never actually landed in IndexedDB. Right then I decided—I had to make automated tests catch these “UI success but data goes nowhere” spooky bugs.
Here’s how I used Playwright’s network interception to automatically verify that response data actually gets written into client-side persistent storage (IndexedDB, localStorage, you name it). I’ll lay out every snag I hit, including the things the official docs don’t volunteer.
Breaking Down the Problem
Scenario: Modern frontends lean heavily on client-side storage—IndexedDB, localStorage, Redux Persist—for offline-first and performance gains. Many write operations follow a “change the UI first, then async-sync to the backend” pattern. Under this pattern, a test that only checks whether the DOM says “Success” can be totally fooled by an optimistic update. The API might have returned bad data, or the write might never have reached the store at all.
Root Cause: Typical E2E tests (whether written with Selenium, Cypress, or basic Playwright) do two things: poke the page and assert that visible elements exist. Unless you deliberately pry open the “network black box” and the “storage black box,” you’ll never know whether the API response and the local data actually match.
Why the Usual Approaches Fall Short
- Even after you manually verify once with DevTools, the bug can reappear in the next release because someone, somewhere, forgot error handling inside a
then. - You can’t ask QA to manually clear IndexedDB, capture packets, and eyeball JSON diffs every regression cycle—it’s unbearably expensive and error-prone.
So the entire “intercept request → capture response → read storage → assert consistency” flow has to be fully automated. And Playwright’s native network interception plus page.evaluate is a perfect match for the job.
Solution Design
Tech Stack: Playwright (Python, v1.40+), combined with the built-in assertion library playwright.async_api.expect.
Core Approach:
- Use
page.routeto intercept the target API and callroute.fetch()to send the real request and capture the full response body. - Stash the response data in a Python variable.
- After the user interaction, use
page.evaluateto read the matching data from IndexedDB / localStorage. - Assert that critical fields (id, status, amount, etc.) are identical between the two datasets.
Why Not Cypress / Selenium?
- Cypress has
interceptfor capturing responses, but reading storage still requires plugins or custom commands. Its support for asynchronous APIs like IndexedDB isn’t smooth, and it only runs on Chromium-based browsers. - Selenium’s network interception is crippled—you need a separate proxy (like BrowserMob) that adds too much weight.
- Playwright’s
route.fetch(introduced in v1.29) hands you the real response object directly (status code, body), no manualfulfillconstruction required. This lets us capture the unmodified real backend response and test against the exact production logic.
Core Implementation
First, a universal utility: intercept a request and return the parsed JSON response. This tackles the problem of “how to safely extract the real response body from inside a route handler.”
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)
Quick note: Why not use route.continue_() and then listen for page.on('response')? Because that forces you to maintain a global mapping of requests to responses, which gets messy fast. route.fetch() (Playwright 1.29+) gives you the response right at the interception point—exactly what we need.
Next, we need a function that can safely pull data out of IndexedDB. We’ll use the native IndexedDB API inside page.evaluate and handle its peculiar async behavior where data is only readable after the transaction closes.
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 = transacti
Top comments (0)