把记忆存储的回归测试从手工换成 Playwright + GitHub Actions,线上缺陷降低 80%

凌晨两点十七分,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_requestmain 时自动跑即可。

踩坑记录:官方文档没告诉你的两件事

坑一: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

相关推荐
触底反弹1 小时前
🚀 从 DOM0 级到 React 合成事件:前端事件监听的 20 年演进史
前端·react.js·面试
kyriewen1 小时前
我给前端项目的接口请求套了6层保护——才发现以前一直在裸奔
前端·javascript·面试
颜酱1 小时前
04 | 召回前置准备:搭好召回所需的四个数据库
前端·人工智能·后端
郝亚军3 小时前
如何安装webstorm、Node.js和vue CLI
前端·javascript·vue.js
IT_陈寒3 小时前
React的useEffect依赖项把我坑惨了
前端·人工智能·后端
东方小月3 小时前
从零开发一个Coding Agent:monorepo项目搭建
前端·后端·node.js
葬送的代码人生3 小时前
别再让 AI 瞎写代码了!Vibe Coding 三步法教你写出靠谱代码
前端·设计模式·架构
Shirley~~3 小时前
Code-Review-Graph:面向 AI 辅助代码审查的结构化上下文引擎
前端·ai编程
AI多Agent协作实战派4 小时前
AI多Agent协作系统实战(十七):凌晨4点,我的AI系统在“假装工作“——3个bug同时爆炸的5小时
java·前端·bug