Unit Testing Chrome Extensions with Jest: Complete Testing Guide
Unit testing is the foundation of any reliable software development process, and Chrome extensions are no exception. While extensions share many similarities with traditional web applications, their unique architecture presents specific challenges that require specialized testing approaches. Jest, Facebook’s powerful JavaScript testing framework, provides an excellent toolkit for testing Chrome extension code, but setting it up correctly requires understanding the nuances of extension development.
This comprehensive guide walks you through everything you need to know about unit testing Chrome extensions with Jest. We’ll cover the fundamental concepts, practical implementation strategies, common pitfalls, and advanced techniques that will help you build robust, maintainable extensions with confidence.
Understanding Chrome Extension Architecture for Testing
Before diving into Jest configuration, it’s essential to understand the distinct components of a Chrome extension that require testing. Chrome extensions consist of several execution contexts, each with its own characteristics and testing requirements.
The background service worker (or background script in Manifest V2) runs in an isolated environment with access to Chrome’s extension APIs. This component handles events like browser actions, message passing, and long-running tasks. Testing the service worker requires mocking Chrome APIs since they aren’t available in Node.js environments.
Content scripts execute within the context of web pages, allowing extensions to interact with page DOM and communicate with the background script. These scripts run in a unique environment that combines web page access with limited Chrome API access. Testing content scripts often involves jsdom for DOM manipulation and careful mocking of chrome.runtime API methods.
Popup pages (the HTML interface that appears when clicking the extension icon) run in their own context and have access to Chrome APIs. These components typically require testing both their DOM behavior and their interaction with extension APIs.
Options pages and other HTML surfaces follow similar patterns to popup pages. Understanding these components helps you design appropriate test strategies for each piece of your extension.
Setting Up Jest for Chrome Extension Development
Getting Jest configured for Chrome extension testing requires careful setup. The key challenge is that Chrome’s extension APIs aren’t available in Node.js, so we need to mock them appropriately.
Installation and Basic Configuration
First, install Jest and necessary dependencies:
npm install --save-dev jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom
Create a jest.config.js file in your project root:
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
},
testMatch: [
'**/__tests__/**/*.js',
'**/?(*.)+(spec|test).js'
],
collectCoverageFrom: [
'**/*.js',
'!**/*.test.js',
'!**/node_modules/**',
],
};
Creating the Jest Setup File
The setup file is crucial for mocking Chrome APIs and configuring testing utilities. Create jest.setup.js:
import '@testing-library/jest-dom';
// Mock Chrome extension APIs
global.chrome = {
runtime: {
id: 'test-extension-id',
sendMessage: jest.fn(),
onMessage: {
addListener: jest.fn(),
removeListener: jest.fn(),
},
getURL: jest.fn((path) => `chrome-extension://test-extension-id/${path}`),
getManifest: jest.fn(() => ({
manifest_version: 3,
name: 'Test Extension',
version: '1.0.0',
})),
},
storage: {
local: {
get: jest.fn(),
set: jest.fn(),
remove: jest.fn(),
},
sync: {
get: jest.fn(),
set: jest.fn(),
remove: jest.fn(),
},
},
tabs: {
query: jest.fn(),
sendMessage: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
runtime: {
lastError: null,
},
};
// Mock fetch
global.fetch = jest.fn();
This setup provides a solid foundation for testing Chrome extension code. You can extend these mocks based on your specific extension’s functionality.
Testing Content Scripts with Jest
Content scripts present unique testing challenges because they operate within web pages while also needing to communicate with the extension’s background components. Let’s examine how to test these effectively.
Example: Testing a Content Script
Consider a content script that highlights keywords on a page:
// content/highlighter.js
function highlightKeywords(keywords) {
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
null,
false
);
const textNodes = [];
while (walker.nextNode()) {
textNodes.push(walker.currentNode);
}
textNodes.forEach(node => {
const text = node.textContent;
keywords.forEach(keyword => {
const regex = new RegExp(`(${keyword})`, 'gi');
if (regex.test(text)) {
const span = document.createElement('span');
span.innerHTML = text.replace(regex, '<mark>$1</mark>');
node.parentNode.replaceChild(span, node);
}
});
});
}
function initHighlighter() {
chrome.storage.local.get('keywords', (result) => {
if (result.keywords && result.keywords.length > 0) {
highlightKeywords(result.keywords);
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initHighlighter);
} else {
initHighlighter();
}
module.exports = { highlightKeywords, initHighlighter };
Writing Tests for Content Scripts
Create a test file that properly sets up the DOM environment:
// __tests__/content/highlighter.test.js
const { highlightKeywords, initHighlighter } = require('../../content/highlighter');
// Mock the DOM environment
beforeEach(() => {
document.body.innerHTML = `
<div>
<p>This is a sample paragraph with some content.</p>
<span>Another element with JavaScript and testing keywords.</span>
</div>
`;
chrome.storage.local.get.mockClear();
chrome.storage.local.get.mockImplementation((keys, callback) => {
callback({ keywords: ['JavaScript', 'testing'] });
});
});
describe('highlightKeywords', () => {
test('should highlight matching keywords', () => {
highlightKeywords(['JavaScript', 'testing']);
const marks = document.querySelectorAll('mark');
expect(marks.length).toBe(2);
});
test('should be case-insensitive', () => {
highlightKeywords(['javascript']);
const marks = document.querySelectorAll('mark');
expect(marks.length).toBe(1);
});
test('should handle empty keyword array', () => {
expect(() => highlightKeywords([])).not.toThrow();
});
test('should handle keywords not found in text', () => {
highlightKeywords(['nonexistent']);
const marks = document.querySelectorAll('mark');
expect(marks.length).toBe(0);
});
});
describe('initHighlighter', () => {
test('should retrieve keywords from storage', () => {
initHighlighter();
expect(chrome.storage.local.get).toHaveBeenCalledWith(
'keywords',
expect.any(Function)
);
});
});
Testing Service Workers and Background Scripts
Service workers in Manifest V3 operate differently from traditional background scripts. They don’t persist in memory, which affects how we test them.
Example: Background Message Handler
// background/background.js
function handleMessage(request, sender, sendResponse) {
switch (request.action) {
case 'getTabInfo':
return handleGetTabInfo(request, sender, sendResponse);
case 'saveData':
return handleSaveData(request, sendResponse);
case 'fetchData':
return handleFetchData(request, sendResponse);
default:
sendResponse({ error: 'Unknown action' });
return false;
}
}
async function handleGetTabInfo(request, sender, sendResponse) {
try {
const tab = await chrome.tabs.get(sender.tab.id);
sendResponse({ success: true, data: tab });
} catch (error) {
sendResponse({ success: false, error: error.message });
}
}
async function handleSaveData(request, sendResponse) {
const { key, value } = request;
await chrome.storage.local.set({ [key]: value });
sendResponse({ success: true });
}
async function handleFetchData(request, sendResponse) {
const { url } = request;
try {
const response = await fetch(url);
const data = await response.json();
sendResponse({ success: true, data });
} catch (error) {
sendResponse({ success: false, error: error.message });
}
}
chrome.runtime.onMessage.addListener(handleMessage);
module.exports = { handleMessage, handleGetTabInfo, handleSaveData, handleFetchData };
Writing Tests for Service Workers
// __tests__/background/background.test.js
const {
handleMessage,
handleGetTabInfo,
handleSaveData,
handleFetchData,
} = require('../../background/background');
describe('handleMessage', () => {
let sendResponse;
beforeEach(() => {
sendResponse = jest.fn();
chrome.tabs.get.mockReset();
chrome.storage.local.set.mockReset();
fetch.mockReset();
});
test('should handle getTabInfo action', async () => {
const mockTab = { id: 123, url: 'https://example.com', title: 'Example' };
chrome.tabs.get.mockResolvedValue(mockTab);
const request = { action: 'getTabInfo' };
const sender = { tab: { id: 123 } };
await handleMessage(request, sender, sendResponse);
// Since handleGetTabInfo returns a promise (true for async handlers)
// we need to wait for the async operation
await new Promise(resolve => setTimeout(resolve, 0));
expect(chrome.tabs.get).toHaveBeenCalledWith(123);
expect(sendResponse).toHaveBeenCalledWith({
success: true,
data: mockTab,
});
});
test('should handle saveData action', async () => {
const request = { action: 'saveData', key: 'testKey', value: 'testValue' };
await handleMessage(request, null, sendResponse);
await new Promise(resolve => setTimeout(resolve, 0));
expect(chrome.storage.local.set).toHaveBeenCalledWith({
testKey: 'testValue',
});
expect(sendResponse).toHaveBeenCalledWith({ success: true });
});
test('should handle fetchData action with successful response', async () => {
const mockData = { result: 'success' };
fetch.mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue(mockData),
});
const request = { action: 'fetchData', url: 'https://api.example.com/data' };
await handleMessage(request, null, sendResponse);
await new Promise(resolve => setTimeout(resolve, 0));
expect(fetch).toHaveBeenCalledWith('https://api.example.com/data');
expect(sendResponse).toHaveBeenCalledWith({
success: true,
data: mockData,
});
});
test('should return error for unknown action', async () => {
const request = { action: 'unknownAction' };
await handleMessage(request, null, sendResponse);
await new Promise(resolve => setTimeout(resolve, 0));
expect(sendResponse).toHaveBeenCalledWith({
error: 'Unknown action',
});
});
});
Testing Popup Components
Popup components combine DOM manipulation with Chrome API interactions, requiring a testing approach that addresses both aspects.
Example: Popup Script
// popup/popup.js
document.addEventListener('DOMContentLoaded', async () => {
const saveButton = document.getElementById('save-btn');
const statusElement = document.getElementById('status');
const inputElement = document.getElementById('user-input');
async function loadSettings() {
const result = await chrome.storage.local.get('userSettings');
if (result.userSettings) {
inputElement.value = result.userSettings.input || '';
}
}
async function saveSettings() {
const userInput = inputElement.value;
await chrome.storage.local.set({
userSettings: { input: userInput }
});
statusElement.textContent = 'Settings saved!';
statusElement.className = 'success';
// Notify background script
chrome.runtime.sendMessage({
action: 'settingsUpdated',
settings: { input: userInput }
});
setTimeout(() => {
statusElement.textContent = '';
statusElement.className = '';
}, 2000);
}
saveButton.addEventListener('click', saveSettings);
await loadSettings();
});
module.exports = {};
Testing Popup Logic
// __tests__/popup/popup.test.js
/**
* @jest-environment jsdom
*/
require('../../popup/popup');
describe('Popup Functionality', () => {
beforeEach(() => {
// Set up DOM elements
document.body.innerHTML = `
<input id="user-input" type="text" />
<button id="save-btn">Save</button>
<div id="status"></div>
`;
chrome.storage.local.get.mockReset();
chrome.storage.local.set.mockReset();
chrome.runtime.sendMessage.mockReset();
// Reset DOMContentLoaded handlers by re-running the popup code
jest.resetModules();
require('../../popup/popup');
});
test('should load settings from storage on init', async () => {
chrome.storage.local.get.mockImplementation((keys, callback) => {
callback({ userSettings: { input: 'test value' } });
});
// Trigger DOMContentLoaded
document.dispatchEvent(new Event('DOMContentLoaded'));
await new Promise(resolve => setTimeout(resolve, 0));
const inputElement = document.getElementById('user-input');
expect(inputElement.value).toBe('test value');
});
test('should save settings to storage when button clicked', async () => {
chrome.storage.local.get.mockImplementation((keys, callback) => {
callback({ userSettings: {} });
});
const inputElement = document.getElementById('user-input');
const saveButton = document.getElementById('save-btn');
const statusElement = document.getElementById('status');
inputElement.value = 'new value';
// Trigger click
saveButton.click();
await new Promise(resolve => setTimeout(resolve, 0));
expect(chrome.storage.local.set).toHaveBeenCalledWith({
userSettings: { input: 'new value' }
});
expect(statusElement.textContent).toBe('Settings saved!');
expect(statusElement.className).toBe('success');
});
test('should send message to background on save', async () => {
chrome.storage.local.get.mockImplementation((keys, callback) => {
callback({ userSettings: {} });
});
const inputElement = document.getElementById('user-input');
const saveButton = document.getElementById('save-btn');
inputElement.value = 'test';
saveButton.click();
await new Promise(resolve => setTimeout(resolve, 0));
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({
action: 'settingsUpdated',
settings: { input: 'test' }
});
});
});
Advanced Testing Patterns
Mocking Chrome API Callbacks
Chrome APIs often use callbacks instead of promises, which can make testing tricky. Here’s how to handle this:
test('should handle chrome storage callback pattern', () => {
// Create a callback-style function to test
function getStoredValue(key, callback) {
chrome.storage.local.get(key, (result) => {
callback(result[key]);
});
}
// Mock the callback behavior
chrome.storage.local.get.mockImplementation((key, callback) => {
callback({ testKey: 'testValue' });
});
const callback = jest.fn();
getStoredValue('testKey', callback);
expect(callback).toHaveBeenCalledWith('testValue');
});
Testing Event Handlers
Chrome extensions heavily use event-driven patterns. Testing these requires careful mocking:
describe('Event Handler Testing', () => {
test('should register and trigger runtime.onMessage', () => {
const messageHandler = jest.fn();
chrome.runtime.onMessage.addListener(messageHandler);
// Simulate message being sent
const listener = chrome.runtime.onMessage.addListener.mock.calls[0][0];
listener({ action: 'test' }, {}, jest.fn());
expect(messageHandler).toHaveBeenCalled();
});
});
Best Practices for Chrome Extension Testing
Following these practices ensures your tests remain maintainable and provide genuine value:
Mock Chrome APIs consistently: Create a shared mock configuration that all tests use. This ensures consistency and makes updates easier when Chrome API changes.
Test each component in isolation: Content scripts, background workers, and popup code have different execution contexts. Test each separately with appropriate mocks.
Use descriptive test names: Clearly describe what each test validates. Names like should highlight matching keywords in text content provide more value than test 1.
Keep tests focused: Each test should validate one specific behavior. This makes debugging easier and provides clearer feedback when something breaks.
Automate mock cleanup: Use beforeEach and afterEach hooks to reset mocks between tests, preventing test pollution.
Test error handling: Ensure your code handles Chrome API failures gracefully by mocking error conditions and verifying appropriate responses.
Continuous Integration for Chrome Extensions
Running tests in CI requires additional considerations. Here’s a GitHub Actions workflow:
name: Test Chrome Extension
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test -- --coverage
- name: Upload coverage
uses: codecov/codecov-action@v3
This workflow runs your Jest tests on every push, ensuring that changes don’t break existing functionality.
Conclusion
Unit testing Chrome extensions with Jest is essential for building reliable, maintainable extensions. While the unique architecture of extensions presents specific challenges, proper setup and testing patterns make comprehensive testing achievable.
The key takeaways from this guide include understanding Chrome extension architecture components, properly mocking Chrome APIs in your Jest configuration, testing content scripts with jsdom, testing background service workers with appropriate async handling, testing popup components for both DOM and API interactions, and following best practices for maintainable tests.
By implementing these testing strategies, you’ll catch bugs early, refactor with confidence, and deliver higher-quality Chrome extensions to your users. Remember that testing is an investment that pays dividends in reduced bugs, easier maintenance, and better user experiences.
Start small, build your test suite incrementally, and enjoy the peace of mind that comes with well-tested code.