Mocking Chrome APIs for Extension Testing: Complete Guide
Testing Chrome extensions presents unique challenges that set them apart from traditional web applications. While web apps run in a relatively predictable browser environment, Chrome extensions interact with privileged browser APIs, service workers, content scripts, and multiple execution contexts simultaneously. The Chrome APIs that extensions rely on—such as chrome.storage, chrome.tabs, chrome.runtime, and chrome.alarms—are not available in standard Node.js testing environments, making traditional unit testing approaches insufficient.
This comprehensive guide teaches you how to mock Chrome APIs effectively, enabling you to write reliable, maintainable unit tests for your extensions. We’ll explore various mocking strategies, popular libraries, and best practices that will help you build a robust testing foundation for your Chrome extension projects.
Understanding the Challenge of Chrome Extension Testing
Chrome extensions operate in a privileged environment with access to APIs that regular web pages cannot use. These APIs enable extensions to read browser history, manage tabs, interact with downloads, modify network requests, and access user data. While powerful, this privileged access creates significant testing challenges.
When you write unit tests for your extension code, you face an immediate problem: the Chrome APIs your code depends on do not exist outside the Chrome browser environment. Attempting to run tests that call chrome.storage.local.get() or chrome.runtime.sendMessage() in a standard Node.js environment will result in errors, as these global chrome objects are undefined.
The solution is mocking—creating fake implementations of Chrome APIs that behave similarly to the real thing but run entirely in your test environment. Effective mocking allows you to test your extension logic without launching a browser, run tests in continuous integration pipelines, and simulate edge cases that would be difficult to reproduce with real Chrome APIs.
Setting Up Your Testing Environment
Before implementing mocks, you need to configure your testing environment properly. Most Chrome extension developers use Jest as their test runner due to its popularity, excellent mocking capabilities, and wide ecosystem of plugins.
Create a basic Jest configuration for your extension:
// jest.config.js
module.exports = {
testEnvironment: 'node',
transform: {
'^.+\\.(js|jsx|ts|tsx)$': 'babel-jest',
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
testMatch: ['**/__tests__/**/*.test.(ts|js)'],
collectCoverageFrom: [
'src/**/*.{ts,js}',
'!src/**/*.d.ts',
],
};
You’ll also need to install appropriate development dependencies:
npm install --save-dev jest @types/jest ts-jest babel-jest @babel/core @babel/preset-env @babel/preset-typescript
For TypeScript projects, create a tsconfig.json for testing:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020", "DOM"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*", "__tests__/**/*"]
}
Manual Chrome API Mocking
The most straightforward approach to mocking Chrome APIs is creating your own mock implementations. This gives you complete control over how the APIs behave and allows you to simulate any scenario your tests require.
Creating a Basic Chrome Mock
Start by creating a mocks directory in your project:
mkdir -p __mocks__/chrome
Create a mock for the chrome.storage API:
// __mocks__/chrome/storage.js
// In-memory storage for testing
const storage = {};
const listeners = {};
export default {
local: {
get: jest.fn((keys, callback) => {
setTimeout(() => {
const result = {};
if (typeof keys === 'string') {
result[keys] = storage[keys];
} else if (Array.isArray(keys)) {
keys.forEach(key => {
result[key] = storage[key];
});
} else if (typeof keys === 'object') {
Object.keys(keys).forEach(key => {
result[key] = storage[key] !== undefined ? storage[key] : keys[key];
});
}
if (callback) callback(result);
return result;
}, 0);
}),
set: jest.fn((items, callback) => {
setTimeout(() => {
Object.assign(storage, items);
// Notify listeners
Object.values(listeners).forEach(listener => listener(items));
if (callback) callback();
}, 0);
}),
remove: jest.fn((keys, callback) => {
setTimeout(() => {
if (typeof keys === 'string') {
delete storage[keys];
} else if (Array.isArray(keys)) {
keys.forEach(key => delete storage[key]);
}
if (callback) callback();
}, 0);
}),
clear: jest.fn((callback) => {
setTimeout(() => {
Object.keys(storage).forEach(key => delete storage[key]);
if (callback) callback();
}, 0);
}),
},
onChanged: {
addListener: jest.fn((callback) => {
listeners['storage'] = callback;
}),
removeListener: jest.fn((callback) => {
delete listeners['storage'];
}),
},
};
Mocking Chrome Runtime API
The chrome.runtime API is essential for extension communication and lifecycle management:
// __mocks__/chrome/runtime.js
const messageListeners = [];
export default {
id: 'test-extension-id',
lastError: null,
getURL: jest.fn((path) => {
return `chrome-extension://test-extension-id/${path}`;
}),
sendMessage: jest.fn((message, responseCallback) => {
setTimeout(() => {
if (responseCallback) {
responseCallback({ success: true });
}
}, 0);
return true;
}),
onMessage: {
addListener: jest.fn((callback) => {
messageListeners.push(callback);
}),
removeListener: jest.fn((callback) => {
const index = messageListeners.indexOf(callback);
if (index > -1) messageListeners.splice(index, 1);
}),
hasListener: jest.fn((callback) => {
return messageListeners.includes(callback);
}),
},
onInstalled: {
addListener: jest.fn(),
},
getManifest: jest.fn(() => ({
manifest_version: 3,
name: 'Test Extension',
version: '1.0.0',
})),
};
Using the Jest-Chrome Library
While manual mocking gives you complete control, the jest-chrome library provides pre-built mocks for most Chrome APIs, significantly reducing boilerplate code.
Installation and Setup
Install jest-chrome as a development dependency:
npm install --save-dev jest-chrome
Configure Jest to use jest-chrome in your configuration:
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest-chrome-setup.js'],
globals: {
chrome: require('jest-chrome'),
},
};
Create the setup file:
// jest-chrome-setup.js
import 'jest-chrome/extend';
Using Jest-Chrome Mocks
With jest-chrome configured, you can write tests that automatically have access to mocked Chrome APIs:
// __tests__/storage-manager.test.js
import { chrome } from 'jest-chrome';
describe('StorageManager', () => {
beforeEach(() => {
// Clear storage before each test
chrome.storage.local.get.mockClear();
chrome.storage.local.set.mockClear();
});
test('should save user preferences', async () => {
const StorageManager = require('../../src/utils/StorageManager').default;
const manager = new StorageManager();
await manager.savePreferences({ theme: 'dark', notifications: true });
expect(chrome.storage.local.set).toHaveBeenCalledWith({
preferences: { theme: 'dark', notifications: true },
});
});
test('should retrieve user preferences', async () => {
chrome.storage.local.get.mockImplementation((keys, callback) => {
callback({ preferences: { theme: 'light' } });
});
const StorageManager = require('../../src/utils/StorageManager').default;
const manager = new StorageManager();
const prefs = await manager.getPreferences();
expect(prefs).toEqual({ theme: 'light' });
});
});
Mocking Chrome Tabs API
The chrome.tabs API is frequently used in extensions for tab management and is essential to mock correctly for comprehensive testing.
// __mocks__/chrome/tabs.js
const tabs = new Map();
let nextTabId = 1;
export default {
create: jest.fn((createProperties, callback) => {
setTimeout(() => {
const tabId = nextTabId++;
const tab = {
id: tabId,
url: createProperties.url || 'about:blank',
active: createProperties.active !== false,
index: createProperties.index || 0,
windowId: createProperties.windowId || 1,
pinned: createProperties.pinned || false,
status: 'loading',
title: 'New Tab',
favIconUrl: '',
};
tabs.set(tabId, tab);
if (callback) callback(tab);
}, 0);
}),
get: jest.fn((tabId, callback) => {
setTimeout(() => {
const tab = tabs.get(tabId);
if (callback) callback(tab || null);
}, 0);
}),
query: jest.fn((queryInfo, callback) => {
setTimeout(() => {
const results = Array.from(tabs.values()).filter(tab => {
if (queryInfo.active !== undefined && tab.active !== queryInfo.active) return false;
if (queryInfo.url !== undefined && tab.url !== queryInfo.url) return false;
if (queryInfo.windowId !== undefined && tab.windowId !== queryInfo.windowId) return false;
return true;
});
if (callback) callback(results);
}, 0);
}),
update: jest.fn((tabId, updateProperties, callback) => {
setTimeout(() => {
const tab = tabs.get(tabId);
if (tab) {
Object.assign(tab, updateProperties);
}
if (callback) callback(tab);
}, 0);
}),
remove: jest.fn((tabIds, callback) => {
setTimeout(() => {
if (Array.isArray(tabIds)) {
tabIds.forEach(id => tabs.delete(id));
} else {
tabs.delete(tabIds);
}
if (callback) callback();
}, 0);
}),
onUpdated: {
addListener: jest.fn(),
removeListener: jest.fn(),
},
onActivated: {
addListener: jest.fn(),
removeListener: jest.fn(),
},
};
Testing Background Service Workers
Background service workers in Manifest V3 extensions handle events and manage extension state. Testing these requires careful mocking of the service worker context.
// __tests__/background-worker.test.js
import { chrome } from 'jest-chrome';
describe('Background Service Worker', () => {
let backgroundWorker;
beforeEach(() => {
jest.resetModules();
chrome.runtime.onMessage.removeListener.mockClear();
chrome.alarms.onAlarm.removeListener.mockClear();
});
test('should handle messages from content scripts', async () => {
const { handleMessage } = require('../src/background/handlers');
const sendResponse = jest.fn();
const message = { type: 'GET_TAB_INFO', tabId: 1 };
chrome.tabs.get.mockImplementation((tabId, callback) => {
callback({ id: 1, url: 'https://example.com', title: 'Example' });
});
await handleMessage(message, {}, sendResponse);
expect(sendResponse).toHaveBeenCalledWith({
tab: { id: 1, url: 'https://example.com', title: 'Example' },
});
});
test('should handle alarm events', async () => {
const { handleAlarm } = require('../src/background/alarms');
const alarm = {
name: 'sync-data',
scheduledTime: Date.now() + 60000,
};
await handleAlarm(alarm);
expect(chrome.storage.local.set).toHaveBeenCalled();
});
});
Simulating Edge Cases and Error Conditions
One of the key benefits of mocking is the ability to test error handling and edge cases that would be difficult to reproduce with real Chrome APIs.
Testing Error Handling
// __tests__/error-handling.test.js
import { chrome } from 'jest-chrome';
describe('Error Handling', () => {
test('should handle storage quota exceeded', async () => {
chrome.storage.local.set.mockImplementation((items, callback) => {
// Simulate quota exceeded error
const error = new Error('QUOTA_BYTES quota exceeded');
error.name = 'QuotaExceededError';
if (callback) callback();
return Promise.reject(error);
});
const StorageManager = require('../../src/utils/StorageManager').default;
const manager = new StorageManager();
await expect(
manager.saveLargeData({ largeString: 'x'.repeat(10000000) })
).rejects.toThrow('QUOTA_BYTES quota exceeded');
});
test('should handle network errors gracefully', async () => {
chrome.runtime.sendMessage.mockImplementation((message, options, callback) => {
// Simulate connection failure
if (callback) {
callback(undefined);
}
chrome.runtime.lastError = { message: 'Could not establish connection' };
return true;
});
const MessageHandler = require('../../src/utils/MessageHandler').default;
const handler = new MessageHandler();
const result = await handler.sendWithRetry({ type: 'FETCH_DATA' }, 3);
expect(result).toBeNull();
});
});
Best Practices for Chrome API Mocking
Following these best practices ensures your mocks are reliable, maintainable, and accurately represent Chrome API behavior.
Keep Mocks Synchronous When Possible
While some Chrome APIs are asynchronous (returning Promises or using callbacks), your mocks can often be synchronous for simpler testing:
// Prefer synchronous mocks for simple cases
storage: {
get: jest.fn((keys) => {
const result = {};
// ... synchronous implementation
return result;
}),
}
Reset Mocks Between Tests
Always clean up mock state between tests to prevent test pollution:
beforeEach(() => {
jest.clearAllMocks();
jest.resetModules();
chrome.storage.local.get.mockReset();
chrome.storage.local.set.mockReset();
});
Use Mock Implementation Instead of Mock Return Value
For complex scenarios, use mockImplementation instead of mockReturnValue:
chrome.storage.local.get.mockImplementation((keys, callback) => {
// Complex logic here
callback(retrievedData);
});
Mock at the Module Level When Appropriate
Mock Chrome APIs at the module level for cleaner tests:
jest.mock('chrome', () => require('../__mocks__/chrome'));
Advanced: Type-Safe Mocks with TypeScript
For TypeScript projects, create type-safe mocks that provide autocomplete and type checking:
// __mocks__/chrome/index.d.ts
import { Storage } from 'chrome-types';
declare module 'chrome-types' {
export const storage: {
local: Storage['local'];
sync: Storage['sync'];
onChanged: Storage['onChanged'];
};
}
Conclusion
Mocking Chrome APIs is an essential skill for any Chrome extension developer who wants to write reliable, maintainable tests. By understanding the challenges of extension testing, implementing appropriate mocks, and following best practices, you can build a comprehensive test suite that catches bugs early and enables confident refactoring.
Start with simple manual mocks for the APIs you use most frequently, and gradually expand your mocking coverage as your test suite grows. Consider using libraries like jest-chrome for common APIs, but don’t hesitate to create custom mocks when you need fine-grained control over behavior simulation.
Remember that good mocks not only make tests pass—they also serve as documentation for how your extension interacts with Chrome APIs. Invest time in creating accurate, well-documented mocks, and your future self will thank you when debugging complex issues or refactoring your extension’s architecture.
Additional Resources
- Jest Documentation
- Chrome Extensions Documentation
- jest-chrome GitHub Repository
- Chrome Extension Testing Best Practices
Common Pitfalls and How to Avoid Them
Even experienced developers encounter challenges when mocking Chrome APIs. Understanding these common pitfalls will help you avoid them in your own projects.
Pitfall 1: Not Matching Async Behavior
Chrome APIs often use callbacks or return Promises, but many developers create synchronous mocks that don’t accurately represent the asynchronous nature of the real APIs. This can lead to tests passing when they shouldn’t, because race conditions are not properly tested.
The solution is to ensure your mocks properly handle asynchronous operations. When a real Chrome API returns a Promise, your mock should do the same:
// Correct async mock implementation
storage: {
get: jest.fn((keys) => {
return new Promise((resolve) => {
setTimeout(() => {
const result = {};
keys.forEach(key => {
result[key] = storage[key];
});
resolve(result);
}, 0);
});
}),
}
Pitfall 2: Forgetting to Reset State
Mock state can leak between tests if you don’t properly reset it. This causes flaky tests that pass sometimes and fail other times, making debugging extremely difficult. Always use beforeEach hooks to reset mock state:
describe('StorageManager', () => {
beforeEach(() => {
jest.clearAllMocks();
// Also reset your mock storage data
Object.keys(storage).forEach(key => delete storage[key]);
});
});
Pitfall 3: Not Mocking All Required Methods
When you only mock the methods you think you’re using, you might miss edge cases where other methods are called implicitly. For instance, the chrome.runtime.lastError property is often checked by Chrome APIs after asynchronous operations:
test('should handle runtime errors', () => {
chrome.runtime.lastError = { message: 'Extension context invalidated' };
// Your code might check lastError
const result = someFunction();
expect(result).toBeNull();
// Important: clear lastError after test
chrome.runtime.lastError = undefined;
});
Pitfall 4: Overly Complex Mocks
While it’s important to accurately represent Chrome API behavior, creating mocks that are too complex defeats the purpose of unit testing. If your mock logic is nearly as complex as the code you’re testing, consider refactoring either the mock or the code itself.
Integration Testing with Mocks
Beyond unit testing, mocks play a crucial role in integration testing scenarios where multiple components work together. Integration tests verify that your extension’s various parts communicate correctly, even when the Chrome APIs they depend on are mocked.
Consider a scenario where your extension’s popup needs to communicate with the background service worker:
// __tests__/integration/popup-background-communication.test.js
import { chrome } from 'jest-chrome';
describe('Popup to Background Communication', () => {
test('should send message from popup and receive response', async () => {
// Set up the background to respond to messages
chrome.runtime.onMessage.addListener.mockImplementation((message, sender, sendResponse) => {
if (message.type === 'GET_SETTINGS') {
sendResponse({ theme: 'dark', language: 'en' });
}
return true;
});
// Simulate popup sending a message
const response = await new Promise((resolve) => {
chrome.runtime.sendMessage(
{ type: 'GET_SETTINGS' },
(response) => resolve(response)
);
});
expect(response).toEqual({ theme: 'dark', language: 'en' });
});
test('should handle message delivery failure', async () => {
chrome.runtime.sendMessage.mockImplementation((message, options, callback) => {
if (callback) callback(undefined);
chrome.runtime.lastError = { message: 'Could not establish connection' };
return true;
});
const result = await new Promise((resolve) => {
chrome.runtime.sendMessage(
{ type: 'GET_SETTINGS' },
(response) => resolve(response)
);
});
expect(result).toBeUndefined();
expect(chrome.runtime.lastError).toBeDefined();
});
});
Mocking for Different Testing Contexts
Chrome extensions run in multiple contexts, each with different API availability. Your mocks should reflect these differences.
Content Script Mocks
Content scripts operate in the context of web pages but still have access to some Chrome APIs:
// __mocks__/chrome/content-script.js
export default {
runtime: {
sendMessage: jest.fn(),
onMessage: {
addListener: jest.fn(),
},
},
storage: {
local: {
get: jest.fn(),
set: jest.fn(),
},
},
tabs: {
query: jest.fn(),
},
};
Popup Page Mocks
Popups have access to most Chrome APIs but have a limited lifetime:
// __mocks__/chrome/popup.js
export default {
runtime: {
getManifest: jest.fn(() => ({
name: 'Test Extension',
version: '1.0.0',
})),
sendMessage: jest.fn(),
},
storage: {
local: {
get: jest.fn(),
set: jest.fn(),
},
},
action: {
setBadgeText: jest.fn(),
setBadgeBackgroundColor: jest.fn(),
},
};
Performance Considerations
When writing tests that extensively use mocks, performance can become a concern, especially in large test suites. Here are some tips to keep your tests fast:
-
Use module-level mocks: Instead of creating new mocks for each test, use
jest.mock()at the module level to reuse mock instances. -
Lazy initialization: Only initialize complex mock data when it’s actually needed in a test.
-
Avoid unnecessary async: If your test doesn’t need to verify async behavior, use synchronous mocks to reduce overhead.
-
Mock selectively: Use
jest.spyOn()to mock only specific methods rather than replacing entire modules.
Conclusion
Mocking Chrome APIs is an essential skill for any Chrome extension developer who wants to write reliable, maintainable tests. By understanding the challenges of extension testing, implementing appropriate mocks, and following best practices, you can build a comprehensive test suite that catches bugs early and enables confident refactoring.
Start with simple manual mocks for the APIs you use most frequently, and gradually expand your mocking coverage as your test suite grows. Consider using libraries like jest-chrome for common APIs, but don’t hesitate to create custom mocks when you need fine-grained control over behavior simulation.
Remember that good mocks not only make tests pass—they also serve as documentation for how your extension interacts with Chrome APIs. Invest time in creating accurate, well-documented mocks, and your future self will thank you when debugging complex issues or refactoring your extension’s architecture.
As Chrome continues to evolve the extension platform, particularly with the ongoing transition to Manifest V3, having robust tests becomes even more critical. Mocked APIs allow you to adapt your test suite quickly when Chrome introduces new APIs or changes existing behavior, ensuring your extension remains reliable through platform updates.
Finally, don’t forget that mocks are a means to an end, not the end itself. The goal is not perfect mock implementation but rather building confidence that your extension works correctly. Balance the effort you put into mocking with the value those tests provide, and you’ll create an extension that serves your users well while remaining maintainable over time.