「Chrome拡張機能は、単体テスト(VitestやJest)だけでは品質を保証しきれない」
開発が進むにつれて、この確信は強くなっていった。
Reactコンポーネント単体のレンダリングをいくらテストしたところで、実際のブラウザ上で「新しいタブを開き、ウィジェットを追加し、タブを再読み込みしても正しく復元されるか?」という一連のエンドツーエンド(E2E)の動作は証明できない。
自作の新しいタブ拡張機能「ZenithTab」でも初期に Playwright のテストを書いてはいたが、dev サーバーを手で立ててから走らせる運用で、CI にも組み込まれておらず、いつの間にか動かなくなっていた。v1.7.1 で「1コマンドで実行でき、CI(GitHub Actions)で自動的に回る」形に立て直した(Issue #16)ときの設計を解説する。
Playwrightを使ってChrome拡張機能をテストしようとすると、特有の壁にぶつかる。
- 拡張機能のパッケージ(解凍済みフォルダ)をテストごとに毎回ブラウザにロードするのは起動が遅く、CI環境で不安定になりやすい(ヘッドレスモードの制約もある)
- 拡張機能の内部ページ(
chrome-extension://<id>/newtab.html)はIDが環境ごとに変わるため、URLの指定が厄介 - テスト中にページをリロードしたとき、テスト用初期データ(シードデータ)がリセットされて永続化の検証が狂ってしまう
戦略の要:拡張機能を「Webアプリ」として起動する
Playwrightで拡張機能のE2Eテストを高速・安定化させるために採った方針は、テスト実行時に拡張機能をパッケージ化せず、Viteの開発サーバー上でプレーンなWebページとして立ち上げることだ。
ZenithTabでは、エントリーポイントである newtab.html が通常のVite開発サーバー(http://localhost:5173/newtab.html)経由でそのままブラウザに描画できるように設計されている。
ストレージ層のフォールバック設計
「でも、Webページとして開いたら chrome.storage.local が存在しないのでエラーになるのでは?」
その通りだ。そこで、ストレージ抽象化層(src/utils/storage.ts)に次のフォールバックを仕込んでいる。
export const isChromeExtension = (): boolean =>
typeof chrome !== 'undefined' && !!chrome.storage && !!chrome.storage.local;
export async function storageGet<T = any>(key: string, defaultValue?: T): Promise<T | undefined> {
if (isChromeExtension()) {
try {
const result = await chrome.storage.local.get(key);
return result[key] !== undefined ? result[key] : defaultValue;
} catch (err) {
console.warn(`chrome.storage.local.get('${key}') failed, falling back to localStorage`, err);
}
}
// E2E テスト・dev サーバー用フォールバック(キーには zenith_ を前置)
try {
const item = localStorage.getItem(`zenith_${key}`);
return item ? JSON.parse(item) : defaultValue;
} catch {
return defaultValue;
}
}
同じ考え方を chrome.bookmarks や chrome.topSites などにも適用しており、拡張外ではモックデータを返す。この透過的なフォールバックがあるおかげで、アプリコード側は一切テスト用の分岐を意識することなく、Playwrightが操作するヘッドレスChromium上で本物と同じようにデータの読み書き・永続化が動作する。
この方針が「テストしないもの」
正直に書いておくと、このアプローチは chrome.* API そのものの挙動は検証しない。ブックマークの読み取りや topSites の結果、オプショナル権限のダイアログは、E2E ではモックデータで代替される。それらは Vitest 側で chrome をモックした単体テスト(tests/helpers/chrome.ts)が担当し、E2E は「ページとしての振る舞い——描画、操作、永続化、復元」に責任を絞っている。両者の分担を明確にしたほうが、どちらも速く安定する。
Playwrightの設定:Viteサーバーの自動起動とリトライ
playwright.config.ts では、テストコマンドを叩いた瞬間にViteサーバーを自動起動し、テスト完了後に自動停止する webServer ブロックを定義する。
import { defineConfig, devices } from '@playwright/test';
const PORT = 5173;
const BASE_URL = `http://localhost:${PORT}`;
export default defineConfig({
testDir: './tests/e2e',
timeout: 30000,
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, // CI環境では一時的な揺らぎを救うためリトライ
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : [['list']],
use: {
baseURL: BASE_URL,
trace: 'on-first-retry',
screenshot: 'only-on-failure', // 失敗した瞬間のみスクショを撮る
},
webServer: {
command: 'npm run dev',
url: `${BASE_URL}/newtab.html`,
reuseExistingServer: !process.env.CI, // ローカル開発時は起動済みサーバーを再利用
timeout: 120_000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
ローカル開発時は reuseExistingServer: true により、手元で動いている npm run dev に即座に接続して数秒でテストが走る。CI環境ではまっさらな状態からサーバーを立ち上げ、確実にポートを閉じる。npm run test:e2e は playwright test そのものなので、事前準備は何もいらない。
テスト分離と「リロード耐性」を両立するシード注入の技
E2Eテストで最も難しいのが、「各テストは完全に独立したクリーンな状態で始めたいが、テストシナリオの途中でブラウザをリロードしても書いたデータが消えては困る」という矛盾だ。
「ウィジェットを追加した後にページをリロードし、リロード後もウィジェットが残っているか?」を検証したい場合、addInitScript で毎回 localStorage.clear() を呼ぶと、リロードの瞬間にも同じ初期化スクリプトが走ってデータが消えてしまう。
この問題を解決するのが、sessionStorage をガードフラグにした初期化スクリプト注入だ(tests/e2e/newtab.spec.ts)。
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
// 同一テスト内でのリロードでは sessionStorage が維持される
if (sessionStorage.getItem('zenith-e2e-seeded')) return;
// テスト開始時の初回のみフラグを立ててストレージをシード
sessionStorage.setItem('zenith-e2e-seeded', '1');
localStorage.clear();
// 言語を英語(en)で固定し、ランナーのOS言語に依存させない
localStorage.setItem(
'zenith_dashboard_appearance',
JSON.stringify({
language: 'en',
theme: 'dark',
glassBlur: 16,
glassOpacity: 0.45,
borderRadius: '2xl',
compactMode: false,
dockPosition: 'bottom',
})
);
});
await page.goto('/newtab.html');
await expect(page.locator('#zenith-root')).toBeVisible();
});
Playwright はテストごとに新しいブラウザコンテキストを作るので、テスト開始時には sessionStorage は空だ。したがって綺麗にクリアされて初期設定が書き込まれる。
しかし、テストシナリオの最中に await page.reload() が実行されたときは、同一タブ内なので sessionStorage のフラグが残っている。そのため初期化処理はスキップされ、テスト中にウィジェットが書き込んだ最新の localStorage がそのまま保持される。
このわずか数行の工夫によって、「完全なテストの独立性」と「リロードによる永続化テスト」の両立が実現できた。言語を en に固定しているのは、ZenithTab が初回起動時にブラウザの言語で既定のウィジェット名やドックを組み立てるためだ——ランナーが日本語環境なら Todo List が タスク になり、アサーションが全滅する。
リアルなユーザーフローを検証するテストシナリオ
newtab.spec.ts で記述しているシナリオは、ユーザーの実際の行動に忠実だ。
1. ウィジェット追加とリロード永続化の検証
編集モードに入り、カタログからQRコードウィジェットを選んで追加。画面上のウィジェットが8個から9個に増えたことを確認し、リロード後も9個のままであるかをアサートする。
test('edit mode opens the Add Widget catalogue and adding a widget puts it on the grid', async ({ page }) => {
await page.getByTitle('Edit Layout').click();
await page.getByRole('button', { name: 'Add Widget' }).first().click();
const dialog = page.locator('h2', { hasText: 'Add Widget' });
await expect(dialog).toBeVisible();
const qrCard = page.locator('.group', { has: page.getByRole('heading', { name: 'QR Code' }) });
await qrCard.getByRole('button', { name: 'Add Widget' }).click();
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden();
await expect(widgetCards(page)).toHaveCount(9);
await expect(page.getByRole('heading', { name: 'QR Code' })).toBeVisible();
// リロードしてもストレージから正しく復元されるか?
await page.reload();
await expect(widgetCards(page)).toHaveCount(9);
});
2. 外部通信(window.open)の差し替え
検索バーから検索クエリを送信した際、実際に外部の検索エンジンへ飛んでしまうとテストが不安定になる(ヘッドレスブラウザをボットとして弾くサイトもある)。
window.open をページ内で差し替え、正しい検索URLが組み立てられているかだけを検証する。
test('the search bar switches engines and submits a query in a new tab', async ({ page }) => {
await page.evaluate(() => {
(window as any).__opened = [];
window.open = ((url: string) => {
(window as any).__opened.push(String(url));
return null;
}) as typeof window.open;
});
await page.getByRole('button', { name: /DuckDuckGo/ }).first().click();
const input = page.getByPlaceholder(/Search the web/);
await input.fill('zenith tab');
await input.press('Enter');
const opened = await page.evaluate(() => (window as any).__opened as string[]);
expect(opened).toEqual(['https://duckduckgo.com/?q=zenith%20tab']);
});
3. 復元機能(Undo・ゴミ箱・スナップショット)
v1.8.0 で Undo / ゴミ箱 / 自動スナップショットを入れたとき、同じファイルに recovery グループを足した。「削除したウィジェットがトーストの Undo で戻る」「ゴミ箱から復元できる」といったシナリオは、ストアの単体テストで担保できていても、トーストの role="status" が実際に描画されてボタンが押せることまでは E2E でしか確かめられない。
test('a removed widget comes back from the undo toast', async ({ page }) => {
await page.getByTitle('Edit Layout').click();
const clockCard = page.locator('.react-grid-item', { has: page.locator('[data-widget-type="clock"]') });
await clockCard.getByTitle('Remove Widget').click();
await expect(widgetCards(page)).toHaveCount(7);
const toast = page.getByRole('status');
await expect(toast).toContainText('Removed widget "Clock"');
await toast.getByRole('button', { name: 'Undo' }).click();
await expect(widgetCards(page)).toHaveCount(8);
});
GitHub ActionsでのCI自動化と失敗時レポート
このE2Eテスト群は、プッシュやプルリクエストのたびに GitHub Actions(.github/workflows/build.yml)上で、lint・型チェック・単体テスト・ビルド検証を行うジョブとは 別ジョブ として実行される。
e2e:
runs-on: ubuntu-24.04
needs: build-and-test
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright browser
run: npx playwright install --with-deps chromium
# playwright.config.ts が Vite dev サーバーを自分で起動する
- name: Run E2E tests
run: npm run test:e2e
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: playwright-report/
retention-days: 7
もしPRの変更によってUIが崩れたり、クリックが効かなくなったりした場合、CIが即座に赤く光る。
さらに、失敗した瞬間のみPlaywrightが自動撮影したスクリーンショットと、初回リトライ時のトレース、HTMLレポートがアーティファクトとして保存されるため、どの画面のどの要素が見つからずに落ちたのかが一目瞭然になる。
リリースへの恐怖を減らす
Chrome拡張機能の公開は、Webサービスのデプロイと違って「バグがあったから即ロールバック」が難しい。ストアの再審査には数時間から数日かかり、その間ユーザーは不具合のあるバージョンを使い続けることになる。
だからこそ、「手元のコードを変更しても、新規タブの主要な機能が壊れていない」と確信できる自動テスト網が欠かせない。
Viteで素早くWebとして立ち上げ、Playwrightで実画面を愚直に操作させ、CIで常時監視する。
この仕組みを整えたことで、ZenithTabでは v1.10.3 の「1,000行超のストアをスライスに分割」「設定パネルをタブ別コンポーネントに分割」といった大規模なリファクタリングを、既存テストを一切書き換えずに全件パスさせる形で日常的に行える開発サイクルを確立できた。
さいごに:ZenithTabについて
本記事で紹介した設計やトラブルシューティングの知見は、すべて自作の新しいタブChrome拡張機能「ZenithTab(ゼニスタブ)」の開発を通して得られたものです。
ZenithTabは、「ブラウザを開くたびに心地よく、作業に集中できる」をコンセプトにした、完全ローカル完結・プライバシー重視のダッシュボード拡張機能です。
- 自由なグリッド配置: 時計、天気、カレンダー、RSSリーダー、集中タイマー、習慣トラッカー、メモなど、多彩なウィジェットをグリッド上で自由に配置
- 安心のローカル完結: 外部サーバーへのデータ送信は一切行わず、すべての設定やメモはブラウザ内に安全に保存
- 細部へのこだわり: ガラスモーフィズム(すりガラス調UI)、ダイナミック壁紙、軽快なキーボードショートカット、そして万が一の誤操作を防ぐ「元に戻す(Ctrl+Z)」や自己修復機能を完備
Chromeウェブストアで無料公開しています。日々の作業効率化や、技術的なUI/UXの触感のお試しとして、ぜひ気軽に使ってみてください!
- 🌐 Chrome ウェブストアでインストール:
ZenithTab - Chrome ウェブストア - 🐙 GitHub リポジトリ(完全オープンソース):
miyabiver39/ZenithTab
ソースコードはGitHubで公開しています。「面白い」「役に立った」と思っていただけたら、GitHubのスター(⭐️) や記事への いいね / ストック をいただけると、開発の大きな励みになります!

