Chrome Extension Testing Guide: Jest, Puppeteer, and chrome-mock
Test Chrome extensions at three levels: unit-test business logic with Jest + chrome-mock (no browser required), integration-test UI with Puppeteer by loading the unpacked extension, and end-to-end test user flows with HelpMeTest. Use sinon-chrome or jest-chrome to mock the chrome.* APIs in unit tests.
Key Takeaways
Unit test business logic in isolation. Background scripts and content scripts are just JavaScript. Extract pure functions and test them with Jest — no browser needed.
Mock the chrome. API, not the browser.* Libraries like jest-chrome and sinon-chrome replace global.chrome in your test environment, letting you assert on chrome.storage.set calls without a real extension.
Load the unpacked extension for integration tests. Puppeteer's --load-extension flag lets you run the real extension in a real Chromium instance. Use this for popup UI and content script tests.
Get the extension ID programmatically. After loading, read the ID from chrome://extensions or extract it from the background page URL in Puppeteer.
Test storage as state. chrome.storage.local is the main state container for most extensions. Assert on storage reads/writes to verify your extension's state transitions.
Testing Chrome extensions requires a different approach than testing regular web pages. Extensions run in a privileged context, interact with the chrome.* API, and span multiple execution environments: background service workers, content scripts, and popup pages. This guide covers how to test each layer with Jest, Puppeteer, and chrome-mock.
Why Chrome Extension Testing Is Hard
Extensions don't follow the normal web testing playbook:
- Multiple execution contexts — background, content scripts, and popup each have different capabilities and communication channels
- Privileged APIs —
chrome.tabs,chrome.storage,chrome.runtimeare only available inside extensions - No direct DOM access between contexts — the popup can't directly call functions in content scripts
- Extension ID instability — the ID changes unless you set a
keyfield inmanifest.json
The solution is layered testing: unit tests for business logic, integration tests for each context, and end-to-end tests for full user flows.
Setting Up the Test Environment
Install the core dependencies:
npm install --save-dev jest jest-chrome puppeteerConfigure Jest to use jsdom and inject the chrome mock:
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFiles: ['./jest.setup.js'],
};
// jest.setup.js
const chrome = require('jest-chrome');
Object.assign(global, { chrome });jest-chrome exposes the full chrome.* API surface as Jest mock functions. Every chrome.tabs.query, chrome.storage.local.get, and chrome.runtime.sendMessage call becomes a spy you can assert on.
Unit Testing with Jest and chrome-mock
Extract your extension's business logic into pure functions that can be tested without a browser.
Testing Background Script Logic
// background/ruleEngine.js
export function shouldBlockUrl(url, rules) {
return rules.some(rule => url.includes(rule.pattern));
}
export async function updateBadge(tabId, isBlocked) {
const text = isBlocked ? '✕' : '';
await chrome.action.setBadgeText({ tabId, text });
}// background/ruleEngine.test.js
import { shouldBlockUrl, updateBadge } from './ruleEngine';
describe('shouldBlockUrl', () => {
test('blocks URL matching a rule pattern', () => {
const rules = [{ pattern: 'ads.example.com' }];
expect(shouldBlockUrl('https://ads.example.com/banner.js', rules)).toBe(true);
});
test('allows URL with no matching rule', () => {
const rules = [{ pattern: 'ads.example.com' }];
expect(shouldBlockUrl('https://safe.example.com/page', rules)).toBe(false);
});
});
describe('updateBadge', () => {
test('sets badge text to ✕ when blocked', async () => {
await updateBadge(1, true);
expect(chrome.action.setBadgeText).toHaveBeenCalledWith({ tabId: 1, text: '✕' });
});
test('clears badge text when not blocked', async () => {
await updateBadge(1, false);
expect(chrome.action.setBadgeText).toHaveBeenCalledWith({ tabId: 1, text: '' });
});
});Testing Chrome Storage
// storage/settings.js
export async function saveSettings(settings) {
await chrome.storage.local.set({ settings });
}
export async function loadSettings() {
const result = await chrome.storage.local.get('settings');
return result.settings ?? { theme: 'light', enabled: true };
}// storage/settings.test.js
import { saveSettings, loadSettings } from './settings';
describe('settings storage', () => {
beforeEach(() => {
chrome.storage.local.get.mockReset();
chrome.storage.local.set.mockReset();
});
test('saves settings to local storage', async () => {
await saveSettings({ theme: 'dark', enabled: false });
expect(chrome.storage.local.set).toHaveBeenCalledWith({
settings: { theme: 'dark', enabled: false }
});
});
test('returns defaults when no settings stored', async () => {
chrome.storage.local.get.mockImplementation((key, callback) => {
callback({});
});
const settings = await loadSettings();
expect(settings).toEqual({ theme: 'light', enabled: true });
});
});Testing Message Passing
// messaging/messages.test.js
import { handleMessage } from './messages';
describe('message handler', () => {
test('responds to GET_STATUS message', async () => {
const sendResponse = jest.fn();
const message = { type: 'GET_STATUS' };
await handleMessage(message, {}, sendResponse);
expect(sendResponse).toHaveBeenCalledWith({ status: 'active' });
});
test('ignores unknown message types', async () => {
const sendResponse = jest.fn();
await handleMessage({ type: 'UNKNOWN' }, {}, sendResponse);
expect(sendResponse).not.toHaveBeenCalled();
});
});Integration Testing with Puppeteer
For tests that require a real browser, Puppeteer can load your extension via --load-extension.
Setup
// test/setup.js
const puppeteer = require('puppeteer');
const path = require('path');
let browser;
let extensionId;
async function launchBrowserWithExtension() {
const extensionPath = path.resolve(__dirname, '../dist');
browser = await puppeteer.launch({
headless: false, // extensions require non-headless in older versions
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
'--no-sandbox',
],
});
// Get extension ID from service worker target
const targets = await browser.targets();
const extensionTarget = targets.find(
t => t.type() === 'service_worker' && t.url().includes('chrome-extension://')
);
extensionId = new URL(extensionTarget.url()).hostname;
return { browser, extensionId };
}
module.exports = { launchBrowserWithExtension };Testing the Popup
// test/popup.test.js
const { launchBrowserWithExtension } = require('./setup');
describe('Extension Popup', () => {
let browser, extensionId, popupPage;
beforeAll(async () => {
({ browser, extensionId } = await launchBrowserWithExtension());
popupPage = await browser.newPage();
await popupPage.goto(`chrome-extension://${extensionId}/popup.html`);
});
afterAll(() => browser.close());
test('renders the toggle button', async () => {
const toggle = await popupPage.$('#enable-toggle');
expect(toggle).not.toBeNull();
});
test('clicking toggle updates storage', async () => {
await popupPage.click('#enable-toggle');
const storage = await popupPage.evaluate(() =>
new Promise(resolve =>
chrome.storage.local.get('settings', data => resolve(data.settings))
)
);
expect(storage.enabled).toBe(false);
});
test('shows current status from storage', async () => {
await popupPage.evaluate(() =>
new Promise(resolve =>
chrome.storage.local.set({ settings: { enabled: true } }, resolve)
)
);
await popupPage.reload();
const statusText = await popupPage.$eval('#status', el => el.textContent);
expect(statusText).toContain('Active');
});
});Testing Content Scripts
// test/contentScript.test.js
describe('Content Script', () => {
let browser, extensionId, page;
beforeAll(async () => {
({ browser, extensionId } = await launchBrowserWithExtension());
page = await browser.newPage();
await page.goto('https://example.com');
});
test('content script injects its overlay element', async () => {
const overlay = await page.$('#my-extension-overlay');
expect(overlay).not.toBeNull();
});
test('content script responds to runtime messages', async () => {
const response = await page.evaluate(() =>
new Promise(resolve =>
chrome.runtime.sendMessage({ type: 'PING' }, resolve)
)
);
expect(response).toEqual({ type: 'PONG' });
});
});Structuring Your Test Suite
A well-structured Chrome extension test suite follows this hierarchy:
tests/
├── unit/
│ ├── background/ # Pure logic, no browser
│ ├── content/ # DOM manipulation (jsdom)
│ └── storage/ # chrome.storage mock tests
├── integration/
│ ├── popup.test.js # Real popup in real browser
│ ├── content.test.js # Content script on real pages
│ └── background.test.js # Background service worker
└── e2e/
└── userFlow.test.js # Full user scenariosRun unit tests in watch mode during development. Run integration tests in CI with a full Puppeteer setup. Use HelpMeTest for end-to-end user flow tests — write the scenario in plain English and let the AI handle the browser automation.
Common Pitfalls
Don't test the chrome. API itself.* You don't need to verify that chrome.storage.set stores data — Chrome does that. Test that your code calls it with the right arguments.
Reset mocks between tests. jest-chrome mocks persist across tests unless you call mockReset() in beforeEach. Stale mock state causes flaky tests.
Build before running integration tests. Puppeteer loads from your dist/ folder. If you forget to rebuild after a code change, you'll be testing old code.
Handle async message responses properly. Chrome's sendMessage can be async. Use async/await or return true from your onMessage listener to keep the message channel open.
Running Tests in CI
# .github/workflows/test.yml
- name: Build extension
run: npm run build
- name: Run unit tests
run: npm run test:unit
- name: Run integration tests
run: npm run test:integration
env:
DISPLAY: ':99' # For headful Puppeteer on LinuxFor continuous monitoring of your extension's behavior on real web pages, HelpMeTest can run scheduled tests at 5-minute intervals and alert you when something breaks — no infrastructure to manage at $100/month.
Summary
Chrome extension testing works best in layers:
- Jest + jest-chrome for unit testing business logic and storage operations
- Puppeteer for integration testing popup UI, content scripts, and message passing
- HelpMeTest for end-to-end user flow tests and continuous monitoring
Start with unit tests — they run in milliseconds and catch most regressions. Add Puppeteer integration tests for the behaviors that actually require a browser. Use end-to-end tests sparingly for the flows that matter most to users.