DEV Community

BAOFUFAN
BAOFUFAN

Posted on

I Spent 3 Hours Cracking Data Persistence Testing with Playwright Network Interception

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:

  1. Use page.route to intercept the target API and call route.fetch() to send the real request and capture the full response body.
  2. Stash the response data in a Python variable.
  3. After the user interaction, use page.evaluate to read the matching data from IndexedDB / localStorage.
  4. Assert that critical fields (id, status, amount, etc.) are identical between the two datasets.

Why Not Cypress / Selenium?

  • Cypress has intercept for 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 manual fulfill construction 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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Top comments (0)