Chrome Extension Testing Strategies — Developer Guide
17 min readChrome Extension Testing Strategies
A comprehensive guide to testing Chrome Extensions (MV3) across all layers: unit tests, integration tests, and end-to-end tests.
Overview
Chrome extensions operate across multiple execution contexts—service workers, content scripts, popup pages, and options pages—each with unique testing challenges. A robust testing strategy covers all these contexts while handling Chrome-specific APIs.
Testing Pyramid for Extensions
┌─────────────┐
│ Manual │ ← Load unpacked, verify UI
│ Testing │
┌┴─────────────┴┐
│ E2E Tests │ ← Puppeteer/Playwright
│ (Browser) │
┌┴───────────────┴┐
│ Integration │ ← Mocked Chrome APIs
│ Tests │
┌┴────────────────┴┐
│ Unit Tests │ ← Pure logic
│ (Fast, isolated)│
└──────────────────┘
Unit Testing
Unit tests verify pure business logic without Chrome API dependencies. Extract logic into separate modules that can be tested independently.
Test Framework Setup
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
setupFiles: ['./test/setup.ts'],
},
});
Testing Pure Functions
// src/utils/url-parser.ts
export function extractDomain(url: string): string {
try {
return new URL(url).hostname;
} catch {
return '';
}
}
export function isValidExtensionId(id: string): boolean {
return /^[a-z]{32}$/.test(id);
}
// src/utils/url-parser.test.ts
import { describe, it, expect } from 'vitest';
import { extractDomain, isValidExtensionId } from './url-parser';
describe('extractDomain', () => {
it('extracts hostname from URL', () => {
expect(extractDomain('https://example.com/path')).toBe('example.com');
});
it('handles invalid URLs', () => {
expect(extractDomain('not-a-url')).toBe('');
});
});
describe('isValidExtensionId', () => {
it('validates 32-character lowercase IDs', () => {
expect(isValidExtensionId('abcdefghijklmnopqrstuvwxyz012345')).toBe(true);
expect(isValidExtensionId('ABCDEF')).toBe(false);
});
});
Integration Testing
Integration tests verify interactions between extension components with mocked Chrome APIs.
Mocking Chrome Storage
// test/__mocks__/chrome-storage.ts
export const createMockStorage = () => {
const store: Record<string, unknown> = {};
return {
get: vi.fn((keys?: string | string[]) => {
if (!keys) return Promise.resolve({ ...store });
const result: Record<string, unknown> = {};
const keyArray = Array.isArray(keys) ? keys : [keys];
keyArray.forEach(k => { if (k in store) result[k] = store[k]; });
return Promise.resolve(result);
}),
set: vi.fn((items: Record<string, unknown>) => {
Object.assign(store, items);
return Promise.resolve();
}),
remove: vi.fn((keys: string | string[]) => {
const keyArray = Array.isArray(keys) ? keys : [keys];
keyArray.forEach(k => delete store[k]);
return Promise.resolve();
}),
clear: vi.fn(() => {
Object.keys(store).forEach(k => delete store[k]);
return Promise.resolve();
}),
getBytesInUse: vi.fn(() => Promise.resolve(0)),
};
};
// test/setup.ts
import { vi } from 'vitest';
const mockStorage = createMockStorage();
vi.stubGlobal('chrome', {
storage: {
local: mockStorage,
sync: mockStorage,
onChanged: {
addListener: vi.fn(),
},
},
runtime: {
lastError: null,
getURL: (path: string) => `chrome-extension://mock-id/${path}`,
sendMessage: vi.fn(),
onMessage: { addListener: vi.fn() },
onInstalled: { addListener: vi.fn() },
},
});
Testing Message Passing
// src/background/message-handler.ts
export function handleMessage(
message: { type: string; payload?: unknown },
sender: chrome.runtime.MessageSender
): Promise<{ success: boolean; data?: unknown }> {
switch (message.type) {
case 'GET_DATA':
return Promise.resolve({ success: true, data: { key: 'value' } });
case 'SET_DATA':
return chrome.storage.local.set(message.payload as Record<string, unknown>)
.then(() => ({ success: true }));
default:
return Promise.resolve({ success: false, data: 'Unknown message type' });
}
}
// src/background/message-handler.test.ts
import { describe, it, expect, vi } from 'vitest';
import { handleMessage } from './message-handler';
describe('handleMessage', () => {
it('returns data for GET_DATA messages', async () => {
const result = await handleMessage({ type: 'GET_DATA' }, { id: '1' });
expect(result.success).toBe(true);
expect(result.data).toEqual({ key: 'value' });
});
it('stores data for SET_DATA messages', async () => {
const result = await handleMessage(
{ type: 'SET_DATA', payload: { theme: 'dark' } },
{ id: '1' }
);
expect(chrome.storage.local.set).toHaveBeenCalledWith({ theme: 'dark' });
expect(result.success).toBe(true);
});
});
End-to-End Testing
E2E tests run the extension in a real Chrome browser with Puppeteer or Playwright.
Puppeteer Setup
// test/e2e/puppeteer-extension.ts
import puppeteer, { Browser, Page } from 'puppeteer';
import path from 'path';
export async function createExtensionBrowser(
extensionPath: string
): Promise<{ browser: Browser; extId: string }> {
const browser = await puppeteer.launch({
headless: false,
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
'--no-sandbox',
],
});
// Get extension ID
const targets = await browser.targets();
const extTarget = targets.find(t => t.type() === 'service_worker');
const extId = extTarget?.url().match(/chrome-extension:\/\/([^/]+)/)?.[1] || '';
return { browser, extId };
}
export async function runExtensionE2E() {
const extensionPath = path.resolve(__dirname, '../../dist');
const { browser, extId } = await createExtensionBrowser(extensionPath);
try {
// Test popup
const popup = await browser.newPage();
await popup.goto(`chrome-extension://${extId}/popup.html`);
const button = await popup.$('#action-btn');
await button?.click();
const status = await popup.$eval('#status', el => el.textContent);
expect(status).toBe('Active');
// Test content script on real page
const page = await browser.newPage();
await page.goto('https://example.com');
await page.waitForSelector('.injected-element');
const text = await page.$eval('.injected-element', el => el.textContent);
expect(text).toContain('Extension Active');
} finally {
await browser.close();
}
}
Playwright Alternative
// test/e2e/playwright-extension.ts
import { chromium, BrowserContext } from '@playwright/test';
export async function createExtensionContext(
extensionPath: string
): Promise<{ context: BrowserContext; extId: string }> {
const context = await chromium.launchPersistentContext('', {
headless: false,
args: [`--load-extension=${extensionPath}`],
});
// Wait for service worker
let [sw] = context.serviceWorkers();
if (!sw) sw = await context.waitForEvent('serviceworker');
const extId = sw.url().split('/')[2];
return { context, extId };
}
Testing Service Workers
Service workers have unique lifecycle considerations—Chrome can terminate them after inactivity.
Testing Event Registration
// src/background/service-worker.ts
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === 'install') {
chrome.storage.local.set({ installedAt: Date.now() });
}
});
chrome.alarms.create('periodic-sync', { periodInMinutes: 15 });
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'ping') sendResponse({ pong: true });
return true;
});
// src/background/service-worker.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
describe('Service Worker', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('registers all event listeners', () => {
require('./service-worker');
expect(chrome.runtime.onInstalled.addListener).toHaveBeenCalled();
expect(chrome.alarms.create).toHaveBeenCalledWith('periodic-sync', {
periodInMinutes: 15,
});
expect(chrome.runtime.onMessage.addListener).toHaveBeenCalled();
});
it('handles messages correctly', () => {
const listener = chrome.runtime.onMessage.addListener.mock.calls[0][0];
const sendResponse = vi.fn();
listener({ type: 'ping' }, { tab: { id: 1 } }, sendResponse);
expect(sendResponse).toHaveBeenCalledWith({ pong: true });
});
});
Testing SW Persistence
// test/e2e/service-worker-lifecycle.test.ts
import puppeteer from 'puppeteer';
test('state persists after SW restart', async () => {
const browser = await puppeteer.launch({
args: [`--load-extension=${extensionPath}`],
});
// Set initial state via popup
const page = await browser.newPage();
await page.goto(`chrome-extension://${extId}/popup.html`);
await page.fill('#input', 'test-value');
await page.click('#save');
// Terminate service worker
const client = await page.target().createCDPSession();
await client.send('ServiceWorker.stopAllWorkers');
// Wait for SW to restart
await page.waitForTimeout(2000);
// Verify state persisted
await page.reload();
const value = await page.$eval('#display', el => el.textContent);
expect(value).toBe('test-value');
await browser.close();
});
Testing Content Scripts
Content scripts run in the context of web pages and interact with the DOM.
JSDOM for Unit Tests
// test/unit/content-script.test.ts
import { JSDOM } from 'jsdom';
const dom = new JSDOM(`
<!DOCTYPE html>
<div id="container"></div>
`, { url: 'https://example.com' });
global.document = dom.window.document;
global.window = dom.window as Window;
// Test content script logic
function initializeUI() {
const container = document.getElementById('container');
if (!container) return;
container.innerHTML = '<button id="action">Click me</button>';
container.classList.add('initialized');
}
describe('Content Script UI', () => {
beforeEach(() => {
document.getElementById('container')!.innerHTML = '';
});
it('injects UI elements', () => {
initializeUI();
expect(document.querySelector('#action')).toBeTruthy();
expect(document.getElementById('container')?.classList.contains('initialized')).toBe(true);
});
});
Puppeteer for Browser Tests
// test/e2e/content-script.test.ts
import puppeteer from 'puppeteer';
test('content script modifies page', async () => {
const browser = await puppeteer.launch({
args: [`--load-extension=${extensionPath}`],
});
const page = await browser.newPage();
await page.goto('https://example.com');
// Wait for content script injection
await page.waitForSelector('.extension-injected', { timeout: 5000 });
const isVisible = await page.$eval('.extension-injected',
el => window.getComputedStyle(el).display !== 'none'
);
expect(isVisible).toBe(true);
await browser.close();
});
Testing Popup and Options UI
Test popup and options pages as regular web pages with DOM interaction.
// test/e2e/popup.test.ts
import puppeteer from 'puppeteer';
test('popup interactions', async () => {
const { browser, extId } = await createExtensionBrowser(distPath);
const popup = await browser.newPage();
await popup.goto(`chrome-extension://${extId}/popup.html`);
// Test form input
await popup.fill('#username', 'testuser');
await popup.click('#save-btn');
// Verify storage call
expect(chrome.storage.local.set).toHaveBeenCalledWith(
expect.objectContaining({ username: 'testuser' })
);
// Verify UI update
const message = await popup.$eval('.message', el => el.textContent);
expect(message).toContain('Saved');
await browser.close();
});
CI/CD Integration
Automate testing in GitHub Actions with Chrome installed.
# .github/workflows/test.yml
name: Test Suite
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test -- --coverage
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run build
- name: Install Playwright
run: npx playwright install chromium
- name: Run E2E tests
run: npm run test:e2e
env:
CI: true
Cross-References
- Guide: Testing Extensions
- Guide: CI/CD Pipeline
- Patterns: Testing Patterns
- MV3: Testing MV3 Extensions
Related Articles
Related Articles
Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.