Testing
ExtForge ships a testing library at extforge/testing that installs chrome.* fakes globally for Vitest, resets them between tests, and provides Playwright fixtures for E2E browser tests.
Installing the Vitest preset
Section titled “Installing the Vitest preset”Add a single import to your vitest.config.ts setupFiles:
import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { setupFiles: ['extforge/testing/vitest'], },});That is the entire setup. The preset:
- Calls
installChromeFakes()once to mount fake namespaces onglobalThis.chrome - Registers a
beforeEachhook that resets all fakes between tests - Exports the
fakesbag so your tests can interact with them
Import the fakes in your test file:
import { fakes } from 'extforge/testing/vitest';Unit tests
Section titled “Unit tests”chrome.storage.local round-trip
Section titled “chrome.storage.local round-trip”import { fakes } from 'extforge/testing/vitest';import { describe, it, expect } from 'vitest';
describe('storage round-trip', () => { it('stores and retrieves a value', async () => { await chrome.storage.local.set({ count: 42 }); const result = await chrome.storage.local.get('count'); expect(result).toEqual({ count: 42 }); });
it('starts fresh in each test', async () => { // The beforeEach reset wipes all storage state const result = await chrome.storage.local.get('count'); expect(result).toEqual({}); });
it('tracks call counts via spy', async () => { await chrome.storage.local.set({ x: 1 }); expect(fakes.storage.chrome.local.set.calls).toHaveLength(1); });});chrome.runtime.onMessage
Section titled “chrome.runtime.onMessage”Register a listener, fire a message, and await the reply:
import { fakes } from 'extforge/testing/vitest';import { describe, it, expect } from 'vitest';
// the handler under test (normally lives in background/index.ts)function registerHandler() { chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (msg.type === 'PING') { sendResponse({ type: 'PONG' }); return true; // async response } });}
describe('message handler', () => { it('replies PONG to PING', async () => { registerHandler(); const reply = await fakes.runtime.fireOnMessage({ type: 'PING' }); expect(reply).toEqual({ type: 'PONG' }); });});fireOnMessage collects any sendResponse call from your listeners and resolves the promise. If no listener calls sendResponse, the promise resolves undefined.
chrome.tabs.query with seeded tabs
Section titled “chrome.tabs.query with seeded tabs”import { fakes } from 'extforge/testing/vitest';import { describe, it, expect, beforeEach } from 'vitest';
describe('tab query', () => { beforeEach(() => { fakes.tabs.__seed([ { id: 1, url: 'https://example.com/', active: true }, { id: 2, url: 'https://other.com/', active: false }, ]); });
it('returns only active tabs', async () => { const tabs = await chrome.tabs.query({ active: true }); expect(tabs).toHaveLength(1); expect(tabs[0]!.url).toBe('https://example.com/'); });
it('filters by url', async () => { const tabs = await chrome.tabs.query({ url: 'https://other.com/' }); expect(tabs).toHaveLength(1); expect(tabs[0]!.id).toBe(2); });});__seed replaces the tab list entirely. Repeated calls overwrite, not append.
Per-namespace fakes for granular tests
Section titled “Per-namespace fakes for granular tests”If you prefer to skip the global install and construct fakes manually:
import { createRuntimeFake, createStorageFake, createTabsFake, createActionFake, createScriptingFake,} from 'extforge/testing';
const runtime = createRuntimeFake();const storage = createStorageFake();// pass to your code as neededThis pattern is useful for testing code that accepts injected dependencies, or for testing in environments where a pre-existing chrome global conflicts with installChromeFakes().
The not-modeled trap
Section titled “The not-modeled trap”ExtForge fakes only model the APIs listed in the namespace sections below. Accessing an unmodeled method or property throws immediately:
// chrome.history is not modeledchrome.history.search({ text: '' });// Throws: chrome.history.search is not modeled by extforge/testing v1;// supply your own mock or extend the fake.// Docs: https://extforge.arshadshah.com/testing#unmodeledTo extend a fake with your own stub, spread your method over the namespace:
// Extend the global chrome object in your test file(globalThis as any).chrome.history = { search: vi.fn().mockResolvedValue([]),};Or, for the manual-construction path, add the method before passing the fake to your code.
Playwright E2E tests
Section titled “Playwright E2E tests”The fixture
Section titled “The fixture”extforge init scaffolds a fixture at tests/e2e/fixture.ts:
import { test as base, chromium, type BrowserContext } from '@playwright/test';import { resolve, dirname } from 'pathe';import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));const EXT_PATH = resolve(__dirname, '../../dist/chrome');
type Fixtures = { context: BrowserContext; extensionId: string };
export const test = base.extend<Fixtures>({ context: async ({}, use) => { const ctx = await chromium.launchPersistentContext('', { headless: false, args: [ `--disable-extensions-except=${EXT_PATH}`, `--load-extension=${EXT_PATH}`, ], }); await use(ctx); await ctx.close(); }, extensionId: async ({ context }, use) => { let [sw] = context.serviceWorkers(); if (!sw) sw = await context.waitForEvent('serviceworker'); const id = sw.url().split('/')[2]!; await use(id); },});
export const expect = test.expect;Why launchPersistentContext
Section titled “Why launchPersistentContext”Playwright’s launch() creates an ephemeral browser profile that does not support loading extensions. launchPersistentContext creates a real user-data directory and accepts --load-extension. Extensions require this even for a throw-away test profile (pass '' as the path to get a temp directory).
Getting extensionId
Section titled “Getting extensionId”The fixture waits for the service worker to register, then extracts the extension ID from its URL:
// Service worker URL format:// chrome-extension://<id>/background.jsconst id = sw.url().split('/')[2];Use extensionId to construct chrome-extension:// URLs for opening extension pages:
test('opens popup', async ({ page, extensionId }) => { await page.goto(`chrome-extension://${extensionId}/popup.html`); await expect(page.getByRole('heading', { name: 'My Extension' })).toBeVisible();});Opening popup vs. side panel
Section titled “Opening popup vs. side panel”// Popup (static page)await page.goto(`chrome-extension://${extensionId}/popup.html`);
// Side panel (same approach — it's just a page)await page.goto(`chrome-extension://${extensionId}/sidepanel.html`);Headed vs. headless
Section titled “Headed vs. headless”Extensions cannot run headless in Playwright. The fixture sets headless: false. If you try headless: true, Chrome will silently refuse to load the extension and waitForEvent('serviceworker') will time out.
In CI, use a virtual display (Xvfb on Linux) or the playwright/chromium Docker image that ships with a virtual framebuffer.
# GitHub Actions snippet- name: Run E2E tests run: pnpm test:e2e env: DISPLAY: ':99'See /reference/testing/chrome-fakes/ for the complete fake API reference, /reference/testing/vitest-preset/ for the preset reference, and /reference/testing/playwright/ for the Playwright fixture reference.