测试浏览器扩展,和测试一个网页不是同一件事。除了页面渲染,你可能还要准备扩展自己的存储数据、打开它的弹窗(popup)、选项页(options)或侧边栏(side panel)、触发浏览器级事件,并且验证版本升级时旧数据能否正确迁移。官方的扩展指南给出了起点:启动浏览器时加载扩展、等待它的 service worker、拿到扩展 ID。但真正与扩展相关的测试逻辑,仍然需要一套 fixture 和辅助函数来支撑。
本文用一个具体的扩展作为样本,覆盖三类最常见也最容易漏测的场景:安装行为、弹窗交互、版本升级的数据迁移。
样本:一个待办清单扩展
样本扩展是一个简单的待办清单。用户打开弹窗后可以看到任务列表、新增条目、删除已完成的条目:

弹窗中的待办清单示例,任务数据保存在 chrome.storage.local 中
任务保存在 chrome.storage.local 里,弹窗每次打开时读取已保存的列表并渲染。这个结构很典型:一个后台脚本(background / service worker)负责安装与升级时的逻辑,一个弹窗页面负责交互,存储作为二者之间的状态载体。测试要覆盖的正是这三者之间的联动。
环境准备
先安装依赖:
| |
然后把扩展构建到 dist 目录,并在 Playwright 配置里指向构建产物:
| |
这里体现出的第一个价值点:扩展路径进入配置之后,测试用例本身不再需要关心"怎么把扩展装进浏览器"。官方方案里需要自己处理的部分——以持久化上下文启动浏览器、等待 service worker 就绪、解析扩展 ID——被收敛进了 fixture。
需要提醒的是,扩展只能在持久化上下文里加载,这是 Playwright 官方扩展方案的前提。在 CI 环境使用无头模式时,要先确认所用的无头模式支持加载扩展,否则会出现扩展未安装但断言仍"通过"的假绿。
用例一:安装行为
最基础的一类场景是安装时的副作用。样本扩展在安装时会把当前日期写入 storage,用于统计:
| |
对应的测试只有一行断言:
| |
extension fixture 会在每个测试前自动安装扩展;storage.local.expect() 则会等待安装回调把值写入,再匹配以 YYYY-MM-DD 开头的时间戳。这个"等待"很关键——安装回调是异步触发的,直接读存储几乎必然读到空值,这也是手写扩展测试时最常见的假失败来源。
运行:
| |
默认以无头模式运行 Chromium,结果直接输出到终端。想看真实浏览器里的执行过程,加 --headed:
| |
如果扩展没有按预期写入安装日期,测试会明确报错并指出断言内容。相比"跑一遍看看有没有报错",这类断言把安装时序变成了可回归的行为契约。
用例二:弹窗交互
第二类场景是弹窗。做法是先在存储里准备两条任务,打开弹窗验证它们被渲染出来,再删除第一条,最后验证存储里只剩第二条:
| |
extension.openPopup() 返回一个普通的 Playwright Page 实例,因此弹窗内部可以继续使用熟悉的定位器与断言。toContainText 会自动等待任务出现在弹窗里,并且允许列表里有额外条目,但要求这两条按给定顺序出现。
这个用例同时建立了一条跨接口的验证链:写入存储 → 渲染 UI → 交互 → 回写存储。只断言 UI 会漏掉"数据没保存"的缺陷,只断言存储会漏掉"渲染坏了"的缺陷,两者一起才完整。
跑完之后可以打开 HTML 报告,用 trace viewer 逐步骤回看,并查看每一步的页面截图:

trace viewer 中的弹窗截图,可逐步回放每个测试步骤
有一个实现细节需要知道:弹窗是以普通标签页的形式打开的,而不是浏览器原生的弹窗气泡。这是对 Playwright 限制的绕行方案——它支持的 Page API 无法把原生弹窗暴露出来供交互。绝大多数情况下用普通标签页完全可以接受,反而让弹窗内容可以用完整的定位器能力去测试。
用例三:版本升级与数据迁移
第三类场景更进阶,也是线上事故高发区:发布 v2 时改变了存储格式。
v1 里任务保存为字符串数组:
| |
v2 里改为对象数组:
| |
迁移逻辑写在升级回调里,测试可以这样写:
| |
两个关键点:把 v1 的构建产物放在单独目录(如 test/dist-v1),v2 仍放在配置里的 dist;用 test.use({ extensionAutoInstall: false }) 关掉自动安装,让测试先手动安装旧版本。extension.upgrade() 会保留扩展已存储的数据并触发升级回调,v2 在回调里把字符串转成对象,存储断言等待结果出现。
这条用例保证的是"老用户升级后任务还在"。这类缺陷一旦漏到线上,影响的是全部存量用户,而且通常不可回滚——数据已经被新版本改写了。
三个测试一起跑,得到标准的 HTML 报告,包含结果、截图与每个测试的 trace:

三个测试全部通过后的 HTML 报告
扩展测试的四个难点,以及对应打法
把上面三类场景抽象一下,扩展测试区别于普通 Web 测试的难点主要有四个,每个都有对应的处理方式。
状态在浏览器侧,不在页面里。 扩展的数据在 chrome.storage、IndexedDB 或 chrome.runtime 的上下文中,页面刷新不会重置,测试用例之间容易互相污染。处理方式是把"准备存储数据"变成显式的测试步骤(如上面的 storage.local.set),并让 fixture 在每个测试前重新安装扩展,保证起点干净。
异步回调没有可观测的完成信号。 安装与升级回调什么时候执行完,UI 上没有任何提示。处理方式是使用带等待语义的存储断言(expect(...).toMatch(...) 会轮询直到匹配或超时),而不是一次性的读取断言。
原生界面无法直接交互。 弹窗、侧边栏、选项页各有各的打开方式,原生弹窗还无法用标准 Page API 操作。处理方式是使用封装好的打开方法,并接受"弹窗以标签页打开"这一折中;同时把断言重心放在数据侧,UI 侧只验证关键渲染结果。
版本迁移缺少历史数据。 迁移逻辑只在旧数据存在时才执行,全新安装的测试永远覆盖不到。处理方式是把旧版本构建产物作为测试资源保留在仓库里(如 test/dist-v1),在用例里先装旧版、灌数据、再升级。
与官方扩展指南的分工
官方指南解决的是"把扩展装进浏览器"这一件事,具体是三步:以持久化上下文启动 Chromium、等待扩展的 service worker 就绪、解析出这次安装生成的扩展 ID(每次安装都可能不同)。这三步一个都绕不过去,也是手写扩展测试时最先卡住的地方。
封装库做的是把这三步沉到 fixture 里,再补上扩展场景真正缺的那部分能力:对 chrome.storage 的读写与带等待的断言、打开弹窗这类原生界面的方法、以及安装旧版本再升级的版本迁移流程。分工可以这样判断——如果只需要在已加载扩展的普通页面上跑几个用例,用官方方案加一点胶水代码就够;一旦涉及存储断言、弹窗交互或版本迁移,胶水代码的量会迅速超过业务断言本身,这时候就该用封装。
反过来说,也不要指望封装解决所有问题。跨浏览器支持受限于扩展加载能力本身(Chromium 系可行,其余内核需要先验证);原生弹窗气泡、浏览器工具栏按钮、右键菜单这类不在 Page API 覆盖范围内的界面,依然只能靠间接断言或人工验证。把这些边界写进测试策略,比事后踩坑再返工便宜得多。
什么该自动化,什么不该
扩展测试的投入应该集中在三类高杠杆场景上:安装与升级时的数据副作用(一旦出错影响全量用户且不可回滚)、存储与 UI 的一致性(写入与渲染任意一个断链都是严重缺陷)、以及跨版本迁移(线上事故高发区)。这三类都具备"重复发生 + 失败代价高 + 可确定性断言"的特征。
相反,弹窗的视觉细节、动画过渡、一次性配置验证,以及需要真实账号体系或第三方登录的流程,自动化的投入产出比很低,更适合保留在人工回归清单里。判断标准很直接:这条用例失败时,能不能在不打开浏览器的情况下说清哪里坏了?说不清的,先别自动化。
落地到 CI 的几个注意事项
构建产物必须先于测试生成,并且路径在配置里显式声明,避免"测的是上一版代码"。迁移测试需要同时保留多个版本的构建产物,这会让产物目录变大,建议按版本命名并只保留必要的历史版本。
无头模式要先验证能否加载扩展;如果不行,CI 里改用带虚拟显示的有头模式。测试之间必须串行或严格隔离,因为它们共享同一个浏览器 profile 的存储区——这一点与普通 Playwright 测试"每个用例独立上下文"的直觉相反,是最容易踩的坑。
保留 trace 与截图作为失败产物。扩展测试失败时,往往既没有 UI 报错也没有控制台日志,trace viewer 里的存储快照与页面截图是唯一的线索来源。
最后,把"安装—弹窗—迁移"这三条用例作为最小基线,再加业务相关的具体场景。它们覆盖的是扩展这种形态特有的风险,而业务场景覆盖的是产品逻辑本身,两者不能互相替代。
