凌晨两点十七分,PagerDuty 又响了------用户反馈页面刷新后偏好设置全部变回默认值,Dashboard 的自定义布局也丢了。这是我们本月第三次因为前端记忆存储问题半夜爬起来,而每次发布前的"回归测试"都只是产品和 QA 手工把十几个场景点一遍。大家都觉得没问题,但一上线就炸。我盯着监控里那一排 localStorage 写入报错,忽然意识到:我们缺的不是更谨慎的手工验证,而是一套能在每一次 PR 中自动跑起来的端到端存储测试。
问题到底出在哪
我们的产品是一个重度依赖"记忆存储"的 SaaS 后台:用户自定义的侧边栏宽度、表格列顺序、暗色模式开关、甚至多步骤表单的草稿,全都存在浏览器端。技术栈看似简单------localStorage 存轻量键值,IndexedDB 存草稿和大量结构化数据,再加一层内存级 Map 做缓存加速。真正复杂的不是 API,而是和业务纠缠在一起的"存储策略":
- 空间满了如何降级(
localStorage quota exceeded) - 用户登出/登入时哪些数据要清、哪些要跨会话保留
- 多标签页同时写入时,通过
storage事件同步到其他标签页 IndexedDB版本升级时旧数据迁移的兼容逻辑
回归难度在于:这些逻辑环环相扣,任何一个改动都可能影响另一个存储策略。我们试过手动写回归 checklist,但一次完整回归要覆盖 30+ 个用例,耗时 25 分钟以上,还严重依赖测试人员的记忆和经验。稍一疏忽,"切换标签页后草稿覆盖问题"这种深水炸弹就会溜到生产环境。这不是努力不努力的问题,是人力天生不适合做重复精确的事。
为什么选 Playwright + GitHub Actions
要做自动化回归,无非两种思路:一是单元测试 mock 浏览器存储 API,二是真正的浏览器端到端测试。我们立刻放弃了第一种:localStorage 的配额异常、IndexedDB 事务在浏览器进程退出时的行为,这些边界场景 mock 不出来,能 mock 的部分又跟真实世界差太远。必须用真浏览器。
接下来的选型就两句话:不选 Cypress,不选 Selenium。
Cypress 的存储操作 API 其实也够用,但它对多标签页、多窗口的支持一直是个痛------而我们偏偏要测 storage 事件跨标签同步。Selenium 太重,脚本维护成本大,绑定 WebDriver 慢得让人想下班。Playwright 几乎解决了我们所有痛点:原生支持多浏览器、多标签页、context 隔离,内置 page.evaluate() 能直接操作 localStorage/IndexedDB,还能通过 browserContext.storageState() 捕获和恢复整个存储快照。配上 GitHub Actions,每次 PR 推送到 main 时自动用无头 Chromium 跑完整套存储回归,绝对不让你在合并前漏掉一个场景。
核心实现:三个必须自动化的典型用例
业务用例太多,我们抽出了最致命的三个模式:跨标签同步 、IndexedDB 版本升级 、存储配额降级。下面给出可直接运行的 Playwright 测试代码,抽掉了业务细节,但保留了完整的存储交互骨架。
用例一:跨标签页 storage 事件同步
这段代码验证:用户在 A 标签页修改主题设置后,B 标签页能通过 window.addEventListener('storage', ...) 实时同步,而不是等到刷新。
typescript
// storage-sync.spec.ts
import { test, expect } from '@playwright/test';
test('跨标签页同步 localStorage 偏好设置', async ({ browser }) => {
// 创建两个隔离的浏览器上下文,模拟同一用户的两个标签页
const contextA = await browser.newContext();
const contextB = await browser.newContext();
const pageA = await contextA.newPage();
const pageB = await contextB.newPage();
// 让两个页面都监听 storage 事件并写入 window 标记
await pageA.goto('http://localhost:3000');
await pageA.evaluate(() => {
window.addEventListener('storage', (e) => {
if (e.key === 'theme') (window as any).__receivedTheme = e.newValue;
});
});
await pageB.goto('http://localhost:3000');
await pageB.evaluate(() => {
window.addEventListener('storage', (e) => {
if (e.key === 'theme') (window as any).__receivedTheme = e.newValue;
});
});
// 在 A 标签页写入新主题值------注意这里用 evaluate 模拟真实用户操作
await pageA.evaluate(() => localStorage.setItem('theme', 'dark'));
// 给事件传播留一点时间,然后断言 B 标签页收到同步
await pageB.waitForTimeout(300);
const syncedValue = await pageB.evaluate(() => (window as any).__receivedTheme);
expect(syncedValue).toBe('dark');
await contextA.close();
await contextB.close();
});
用例二:IndexedDB 版本升级时的数据迁移
我们的草稿系统基于 idb 库,当数据库版本号提升时,onupgradeneeded 中的迁移逻辑经常被改出问题。这个测试直接在页面里打开 IndexedDB,建表、插入旧版数据,再重新打开一个更高版本的数据库,验证数据是否完整迁移。
typescript
// indexeddb-migration.spec.ts
import { test, expect } from '@playwright/test';
test('IndexedDB 升级后旧数据不丢失', async ({ page }) => {
await page.goto('http://localhost:3000');
// 用页面上下文创建一个版本为 1 的数据库并写入一条老数据
await page.evaluate(() => {
return new Promise<void>((resolve, reject) => {
const req = indexedDB.open('draftDB', 1);
req.onupgradeneeded = () => {
const db = req.result;
const store = db.createObjectStore('drafts', { keyPath: 'id' });
store.add({ id: 1, content: 'v1 老草稿' });
};
req.onsuccess = () => {
req.result.close();
resolve();
};
req.onerror = reject;
});
});
// 现在触发升级:以版本 2 打开,模拟迁移逻辑
const migratedContent = await page.evaluate(() => {
return new Promise<string>((resolve, reject) => {
const req = indexedDB.open('draftDB', 2);
req.onupgradeneeded = () => {
const db = req.result;
// 迁移逻辑:如果旧 store 存在,遍历数据并迁移到新结构
if (db.objectStoreNames.contains('drafts')) {
// 这里业务可能会追加新字段:v2 需要 content_v2
const tx = req.transaction!;
const oldStore = tx.objectStore('drafts');
const newStore = db.createObjectStore('drafts_v2', { keyPath: 'id' });
oldStore.openCursor().onsuccess = (e) => {
const cursor = (e.target as IDBRequest).result;
if (cursor) {
newStore.add({ id: cursor.value.id, content_v2: cursor.value.content });
cursor.continue();
}
};
db.deleteObjectStore('drafts');
}
};
req.onsuccess = () => {
const db = req.result;
const tx = db.transaction('drafts_v2', 'readonly');
const getReq = tx.objectStore('drafts_v2').get(1);
getReq.onsuccess = () => resolve(getReq.result?.content_v2 ?? '');
db.close();
};
req.onerror = reject;
});
});
expect(migratedContent).toBe('v1 老草稿');
});
用例三:存储配额满时的降级策略
当 localStorage 写满时,我们的业务会触发降级------把部分非关键数据清掉,并给用户一个提示。这个测试不是模拟,而是真实地触发 QuotaExceededError。
typescript
// quota-degradation.spec.ts
import { test, expect } from '@playwright/test';
test('localStorage 配额满时触发降级并清理空间', async ({ page }) => {
await page.goto('http://localhost:3000');
// 先填满 localStorage(Playwright 页面默认 origin 的配额较小,容易模拟)
await page.evaluate(() => {
// 尽量塞满,直到报错
const data = 'x'.repeat(1024 * 1024); // 1MB 块
for (let i = 0; i < 20; i++) {
try {
localStorage.setItem(`stress_${i}`, data);
} catch (e) {
break;
}
}
});
// 调用业务中的保存函数,预期返回降级状态
const result = await page.evaluate(() => {
// 模拟应用中的保存逻辑
try {
localStorage.setItem('critical_setting', 'must-keep');
return 'saved';
} catch (e) {
if (e instanceof DOMException && e.name === 'QuotaExceededError') {
// 业务降级:清除非关键前缀数据
Object.keys(localStorage).forEach(
(k) => k.startsWith('stress_') && localStorage.removeItem(k)
);
// 重新保存
localStorage.setItem('critical_setting', 'must-keep');
return 'degraded';
}
throw e;
}
});
expect(result).toBe('degraded');
// 确保降级后关键数据已写入
const savedValue = await page.evaluate(() => localStorage.getItem('critical_setting'));
expect(savedValue).toBe('must-keep');
});
这三个用例在 playwright.config.ts 里配置成 serial 模式运行(避免并行操作同一域下的存储互相干扰),并通过 GitHub Actions 的矩阵策略同时跑 Chromium 和 Firefox。你只需要在项目根目录放一个 .github/workflows/e2e-storage.yml,触发方式设为 pull_request 到 main 时自动跑即可。
踩坑记录:官方文档没告诉你的两件事
坑一:storageState 复用导致的"僵尸"数据污染
Playwright 提供了 browserContext.storageState() 来保存整个存储快照,这对登录态测试非常友好。但我们在存储回归测试中也用这个特性,想让每个测试从一个共同的初始快照启动,结果发现前一个测试写入的 localStorage 残留数据在下一个测试中依然可见。排查了半天才知道,storageState 只是保存时的快照,你加载后新写入的数据会持续在同一个上下文里积累 。解决办法很简单:存储回归测试绝对不要复用上下文,每个测试都用 browser.newContext() 创建全新容器,跑完就关。这和登录态测试复用完全不同。
坑二:GitHub Actions 的无头 Chromium 对 IndexedDB 文件锁的处理
在 macOS 本地一切正常,上了 GitHub Actions 的 Linux runner 后,indexedDB.open() 偶尔报 AbortError: The transaction was aborted due to an error。查了无数 issue 才搞清楚:无头 Chrome 在 Docker 环境中,有时 /tmp 上的文件锁会因为并发打开同一个数据库而产生冲突。我们的解决方式是在 Playwright 的 launchOptions 里强制指定 --user-data-dir 为一个唯一路径:
typescript
// playwright.config.ts 增量配置
use: {
launchOptions: {
args: ['--user-data-dir=/tmp/playwright-storage-' + Date.now()],
},
},
效果:从"怕上线"到"合完代码就下班"
在加入自动化之前,我们平均每月会收到 4 个线上的存储相关缺陷,其中至少一半是回归问题。接入 Playwright + GitHub Actions 之后,CI 流水线在一个月内拦截了 11 次记忆存储回归问题------全部发生在 PR 阶段,开发者在合并前就修掉了。同期生产环境同类缺陷从 4 个降到了 0。不是手工测试不努力,而是机器真的一遍都不会忘。
一些更直观的数据对比:
| 指标 | 手工回归时期 | 自动化回归后 |
|---|---|---|
| 单次回归耗时 | 25 min+ | 2 min 45 s(CI 并行) |
| 月线上存储缺陷 | 4 个 | 0 个 |
| 回归覆盖用例数 | 约 12(人记不住) | 38 个(全部固化) |
| 测试执行的信心 | "应该差不多了吧" | "绿灯一亮就可以合" |
这套测试连同 Playwright 配置,我已经整理成一个可直接复用的模板仓库,稍加改造就能套到任何依赖浏览器存储的前端项目里。哪怕你们现在只有一个 localStorage.setItem,它也值得一条自动化回归。
#Playwright #前端测试 #GitHubActions #记忆存储 #端到端测试
关于作者
一个常年和后端 API 打交道的架构师,现在花了大量精力在前端存储的稳定性上------因为线上不出事故才是真架构。写代码、写测试、写复盘,偶尔也写写开源工具。
GitHub: github.com/baofugege
Sponsor: github.com/sponsors/ba... --- 如果这篇文章帮你省下了半夜修 bug 的时间,请我喝杯咖啡
提供服务:Python 后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege