测试策略
Chrome 插件开发指南 · 单元测试、Puppeteer、CI
扩展测试的难点是没有浏览器就没有 chrome.*。所以策略的核心是:把纯逻辑和 chrome.* 调用分开——前者用普通单测覆盖,后者交给端到端测试。
分层
┌─────────────────────────────────────────┐
│ E2E(Puppeteer / Playwright) │ 少而关键:真装进 Chrome 跑
│ 覆盖:跨上下文流程、真实注入、权限 │
├─────────────────────────────────────────┤
│ 集成(mock chrome.*) │ 中等:验证 API 调用参数正确
├─────────────────────────────────────────┤
│ 单元(vitest / jest) │ 大量:纯函数、数据处理、解析
└─────────────────────────────────────────┘投入产出比最高的是最下面一层——前提是你的代码结构允许。
让代码可测:把 chrome.* 挤到边缘
// ✗ 难测:逻辑和 API 揉在一起
export async function addTodo(text) {
const { todos = [] } = await chrome.storage.local.get('todos');
if (!text.trim()) throw new Error('空');
todos.push({ id: Date.now(), text: text.trim(), done: false });
await chrome.storage.local.set({ todos });
}// ✓ 好测:纯函数 + 薄壳
export function makeTodo(text, now) {
if (!text.trim()) throw new Error('空');
return { id: now, text: text.trim(), done: false };
}
export function addTodoTo(todos, todo) {
return [...todos, todo];
}
// 薄壳只做读写,几乎没有逻辑
export async function addTodo(text) {
const { todos = [] } = await chrome.storage.local.get('todos');
await chrome.storage.local.set({ todos: addTodoTo(todos, makeTodo(text, Date.now())) });
}makeTodo / addTodoTo 用普通 vitest 就能测,不需要任何浏览器环境。扩展里绝大多数 bug 在这一层,而这一层根本不需要 chrome.*。
单元测试
pnpm add -D vitest// vitest.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom', // 需要 DOM 时
setupFiles: ['./test/setup.js'],
},
});// todo.test.js
import { describe, it, expect } from 'vitest';
import { makeTodo, addTodoTo } from '../src/todo.js';
describe('makeTodo', () => {
it('去掉首尾空格', () => {
expect(makeTodo(' 买菜 ', 1).text).toBe('买菜');
});
it('拒绝空文本', () => {
expect(() => makeTodo(' ', 1)).toThrow();
});
});Mock chrome.*
需要测薄壳层时,两个选择。
手写 mock(够用且透明)
// test/setup.js
import { vi, beforeEach } from 'vitest';
const store = new Map();
globalThis.chrome = {
storage: {
local: {
get: vi.fn(async (keys) => {
if (keys === null) return Object.fromEntries(store);
const list = Array.isArray(keys) ? keys : [keys];
return Object.fromEntries(list.filter((k) => store.has(k)).map((k) => [k, store.get(k)]));
}),
set: vi.fn(async (obj) => {
for (const [k, v] of Object.entries(obj)) store.set(k, v);
}),
clear: vi.fn(async () => store.clear()),
},
},
runtime: {
sendMessage: vi.fn(async () => ({})),
onMessage: { addListener: vi.fn() },
lastError: undefined,
},
tabs: { query: vi.fn(async () => [{ id: 1, url: 'https://example.com/' }]) },
};
beforeEach(() => {
store.clear();
vi.clearAllMocks();
});手写 mock 的好处是你知道它在干什么——现成库经常在你需要某个行为时恰好没实现。
现成方案
sinon-chrome—— 老牌,API 齐全,但基于 sinon。@types/chrome—— 类型定义,配 TypeScript 用。vitest-chrome—— 较新,vitest 友好。
别过度投入 mock 测试。 mock 只能验证「你按预期调用了 API」,不能验证「Chrome 真的会那样响应」。
比如 mock 里 sendMessage 立刻 resolve,但真实环境中它可能因为没有接收方而 reject——mock 测试全绿,线上照样炸。
把 mock 测试留给数据变换逻辑,把行为正确性交给 E2E。
E2E:真的把扩展装进 Chrome
Puppeteer 和 Playwright 都支持加载扩展,但必须用有头模式或新版 headless。
Puppeteer
import puppeteer from 'puppeteer';
import path from 'node:path';
const EXT = path.resolve('./dist');
const browser = await puppeteer.launch({
headless: 'new',
args: [
`--disable-extensions-except=${EXT}`,
`--load-extension=${EXT}`,
],
});拿到扩展 ID(后续访问 popup 等页面要用):
const worker = await browser.waitForTarget((t) => t.type() === 'service_worker');
const extensionId = new URL(worker.url()).host;--load-extension 在旧的 headless 模式下不工作(headless: true / headless: 'old')。必须用 headless: 'new' 或 headless: false。
CI 上跑有头模式需要虚拟显示:
- run: xvfb-run --auto-servernum pnpm test:e2e测 popup
const page = await browser.newPage();
await page.goto(`chrome-extension://${extensionId}/popup.html`);
await page.type('#input', '买菜');
await page.click('#add');
const items = await page.$$eval('.todo', (els) => els.map((e) => e.textContent));
expect(items).toContain('买菜');注意:以标签页方式打开的 popup 不会被「关闭即销毁」,所以依赖那个行为的 bug 在这里测不出来。
测 content script
const page = await browser.newPage();
await page.goto('https://example.com/');
await page.waitForSelector('.myext-injected'); // 等注入生效
const text = await page.$eval('.myext-injected', (e) => e.textContent);
expect(text).toBe('已处理');E2E 里一定要 waitForSelector 而不是 waitForTimeout——注入时机受 run_at 和页面加载速度影响,固定等待必然出现偶发失败。
测 Service Worker
const workerTarget = await browser.waitForTarget((t) => t.type() === 'service_worker');
const sw = await workerTarget.worker();
// 在 SW 上下文里执行代码
const result = await sw.evaluate(async () => {
await chrome.storage.local.set({ test: 1 });
return (await chrome.storage.local.get('test')).test;
});
expect(result).toBe(1);worker.evaluate 能直接在 SW 里跑代码,这是验证后台逻辑最直接的手段。
Playwright
Playwright 的写法类似,但只支持 persistent context:
import { chromium } from '@playwright/test';
const context = await chromium.launchPersistentContext('', {
headless: false,
args: [`--disable-extensions-except=${EXT}`, `--load-extension=${EXT}`],
});
const [worker] = context.serviceWorkers();
const extensionId = new URL(worker.url()).host;CI
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm test # 单元测试
- run: pnpm build # 产出 dist/
- name: E2E
run: xvfb-run --auto-servernum pnpm test:e2e
- uses: actions/upload-artifact@v4
with:
name: extension
path: dist/顺序很重要:E2E 依赖 dist/,必须在 build 之后。
顺手加一个 manifest 校验
// test/manifest.test.js
import { readFileSync } from 'node:fs';
import { describe, it, expect } from 'vitest';
const m = JSON.parse(readFileSync('dist/manifest.json', 'utf-8'));
describe('manifest', () => {
it('是 MV3', () => expect(m.manifest_version).toBe(3));
it('版本号合法', () => expect(m.version).toMatch(/^\d+(\.\d+){0,3}$/));
it('没有开发期权限残留', () => {
expect(m.permissions ?? []).not.toContain('declarativeNetRequestFeedback');
});
it('有上架必需的 128 图标', () => expect(m.icons?.['128']).toBeTruthy());
});这类校验的性价比极高——它拦住的正是「上传到商店才发现」的低级错误,见上架。
该测什么、不该测什么
| 测 | 不测 |
|---|---|
| 数据变换、解析、校验逻辑 | Chrome API 本身的行为 |
| 关键用户流程的 E2E(1~3 条) | 每个按钮的 E2E(慢且脆) |
| manifest 的结构约束 | UI 像素级外观 |
| 消息协议的序列化边界 | mock 里的调用次数 |
扩展的 E2E 天生慢且不稳(要起真浏览器、等注入、等网络)。少而关键是正确策略——覆盖「装上能用」和「核心流程走得通」两件事就够,其余靠单测。
小结
- 核心策略:把纯逻辑和
chrome.*分开,逻辑用普通单测覆盖。 - mock 只验证「你调了什么」,不验证 Chrome 会怎么响应——别过度投入。
- E2E 用 Puppeteer/Playwright +
--load-extension;旧 headless 模式不支持,CI 上配xvfb-run。 worker.evaluate能直接在 SW 里跑代码,是测后台逻辑最直接的办法。- E2E 里永远
waitForSelector,不要固定等待。 - 加一组 manifest 结构校验——性价比最高的测试。
- E2E 少而关键,别追求覆盖率。
下一篇:项目结构。