Last Thursday at 10 PM, our product chat exploded: more than a dozen users reported that all their configurations were lost after a page refresh. My immediate reaction: “Impossible—this is stored in IndexedDB, right?” Opening the browser DevTools revealed empty storage. In that moment it hit me: our testing workflow of “manually open the app, click around, and store things” had completely missed this landmine. I debugged by hand until 3 AM, fixed the issue, only to expose two new bugs. Looking back, those three bugs cost me at least 30 hours. Now I’m breaking down the entire process—and sharing a Playwright-based automated testing approach for IndexedDB so you never have to fly blind again.
Why Are IndexedDB Bugs So Hard to Catch?
Our scenario is common: an SPA admin panel that uses IndexedDB for client-side persistence, caching user preferences, drafts, and recent browsing history. It sounds simple, but the problem is exactly “you think you understand IndexedDB.”
There are three core dimensions to the root causes:
-
Browser behavior differences – The same code triggers completely different storage quota calculations in Chrome, Edge (Chromium-based), and Safari. Safari often silently clears data without even throwing an
error. -
The mental overhead of the async transaction model – IndexedDB’s auto-commit mechanism tricks you into believing your data is safe after
transaction.oncomplete. In reality, the browser can trigger “passive eviction” at any time, forcing you to add an extra layer of defense inside your callbacks. - Tests simply don’t cover enough – Previously, QA would manually open pages and perform operations; scenarios like low storage quotas, private browsing mode, or repeated read/write collisions were never triggered. Traditional E2E frameworks like Cypress or Selenium either require plugins for IndexedDB support or bypass the storage layer entirely with mocks, leading to all-green tests while production burns.
Why don’t typical solutions work? Mocking IndexedDB means you’re testing nothing, and pure manual regression is slow and leaky. We need an approach that precisely controls read/write timing programmatically and asserts storage state in a real browser environment.
Solution Design: Choosing Playwright over Cypress or Puppeteer
I ultimately built a dedicated IndexedDB testing harness with Playwright. The reasons are very practical:
- Native multi-engine support – Chromium, Firefox, and WebKit can all run inside CI, so you can catch Safari-specific behavior without hunting down a Mac.
-
evaluatecan execute arbitrary page scripts – This means I can interact with the IndexedDB API directly inside the page context, just like writing scripts in DevTools, without touching any business code. -
Context-level storage isolation – Playwright’s
BrowserContextcan simulate different storage states, andstorageStateallows saving/restoring them, which is perfect for testing IndexedDB persistence scenarios. -
Why not Cypress – Cypress gives you very weak low-level control over browser storage and awkwardly handles async operations inside
page.evaluate. Puppeteer lacks multi-browser support and the community maintenance pace clearly falls behind Playwright.
The architectural idea: every test case directly operates IndexedDB writes/reads through page.evaluate, bypassing the UI to first ensure the reliability of the storage layer itself. Then layer on E2E scenarios to verify UI state synchronization. With this separation, a 30‑minute manual regression shrinks to 2 seconds and runs inside GitHub Actions.
Core Implementation: Reusable IndexedDB Test Utilities
The code below is entirely based on Playwright and can be dropped straight into your project.
The first piece tackles basic IndexedDB operations, letting us read and write inside tests as freely as if we were using localStorage.
// helpers/indexeddb-helper.ts
// 提供在 Playwright page 内操作 IndexedDB 的通用函数
import { Page } from '@playwright/test';
// 打开数据库并返回句柄(写操作用)
export async function openDB(page: Page, dbName: string, version = 1) {
return page.evaluate(({ dbName, version }) => {
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(dbName, version);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains('store')) {
db.createObjectStore('store', { keyPath: 'id' });
}
};
});
}, { dbName, version });
}
// 写入数据
export async function putData(page: Page, dbName: string, data: any) {
return page.evaluate(({ dbName, data }) => {
return new Promise<string>((resolve, reject) => {
const request = indexedDB.open(dbName);
request.onsuccess = () => {
const db = request.result;
const tx = db.transaction('store', 'readwrite');
const store = tx.objectStore('store');
store.put(data);
tx.oncomplete = () => {
db.close();
resolve('put-success');
};
tx.onerror = () => reject(tx.error);
};
request.onerror = () => rejec
Top comments (0)