Testing Browser Extension Content Scripts: DOM Injection and Page Interaction
Content scripts are the part of a browser extension that runs in the context of web pages — modifying the DOM, reading page content, and communicating with the background script. They're often the most complex part of an extension to test because they combine DOM manipulation with the browser extension API.
This guide covers unit testing content scripts in isolation and integration testing them against real pages.
Content Script Testing Challenges
Content scripts present unique testing challenges:
They run in page context: Unlike background scripts (which are isolated), content scripts execute in the page's JavaScript context. They can access the page DOM but also risk conflicts with the page's own JavaScript.
They communicate with background: Content scripts send and receive messages via chrome.runtime.sendMessage and chrome.runtime.onMessage. This messaging layer needs testing.
They inject UI elements: Many content scripts inject buttons, sidebars, or overlays into pages. Testing that injected UI works correctly requires DOM testing.
Cross-origin restrictions: Content scripts can't access chrome.* APIs that background scripts can. They have limited access to extension storage and can't make cross-origin requests directly.
Unit Testing Content Scripts
Setting Up jsdom for DOM Testing
Content scripts manipulate the DOM, so your test environment needs DOM support. Use jsdom (built into Jest):
// jest.config.js
module.exports = {
testEnvironment: 'jsdom', // Not 'node' — we need DOM
setupFilesAfterFramework: ['./jest.setup.js'],
};// jest.setup.js
// Mock chrome APIs not available in jsdom
global.chrome = {
runtime: {
sendMessage: jest.fn(),
onMessage: {
addListener: jest.fn(),
removeListener: jest.fn(),
},
getURL: jest.fn((path) => `chrome-extension://mock-id/${path}`),
},
storage: {
local: {
get: jest.fn(),
set: jest.fn(),
},
},
};Testing DOM Injection
// content-scripts/tooltip.js
export function injectTooltip(targetElement, text) {
const existing = document.getElementById('ext-tooltip');
if (existing) existing.remove();
const tooltip = document.createElement('div');
tooltip.id = 'ext-tooltip';
tooltip.className = 'ext-tooltip';
tooltip.textContent = text;
const rect = targetElement.getBoundingClientRect();
tooltip.style.top = `${rect.bottom + window.scrollY}px`;
tooltip.style.left = `${rect.left + window.scrollX}px`;
document.body.appendChild(tooltip);
return tooltip;
}
export function removeTooltip() {
const tooltip = document.getElementById('ext-tooltip');
if (tooltip) tooltip.remove();
}// tests/content-scripts/tooltip.test.js
import { injectTooltip, removeTooltip } from '../content-scripts/tooltip';
describe('tooltip injection', () => {
let targetElement;
beforeEach(() => {
document.body.innerHTML = '<div id="target">Hover me</div>';
targetElement = document.getElementById('target');
// jsdom doesn't implement getBoundingClientRect — mock it
targetElement.getBoundingClientRect = jest.fn(() => ({
bottom: 50, left: 100, top: 30, right: 200, width: 100, height: 20
}));
});
afterEach(() => {
document.body.innerHTML = '';
});
it('injects tooltip with correct text', () => {
injectTooltip(targetElement, 'Tooltip text');
const tooltip = document.getElementById('ext-tooltip');
expect(tooltip).not.toBeNull();
expect(tooltip.textContent).toBe('Tooltip text');
});
it('removes existing tooltip before creating new one', () => {
injectTooltip(targetElement, 'First tooltip');
injectTooltip(targetElement, 'Second tooltip');
const tooltips = document.querySelectorAll('#ext-tooltip');
expect(tooltips).toHaveLength(1); // Not 2
expect(tooltips[0].textContent).toBe('Second tooltip');
});
it('positions tooltip below target element', () => {
injectTooltip(targetElement, 'Tooltip');
const tooltip = document.getElementById('ext-tooltip');
// Should be positioned at bottom of target (50px from top)
expect(tooltip.style.top).toBe('50px');
expect(tooltip.style.left).toBe('100px');
});
it('removes tooltip from DOM', () => {
injectTooltip(targetElement, 'Test');
expect(document.getElementById('ext-tooltip')).not.toBeNull();
removeTooltip();
expect(document.getElementById('ext-tooltip')).toBeNull();
});
it('does not error when removing tooltip that does not exist', () => {
expect(() => removeTooltip()).not.toThrow();
});
});Testing Page Content Extraction
// content-scripts/extractor.js
export function extractPageMetadata() {
return {
title: document.title,
url: window.location.href,
description: document.querySelector('meta[name="description"]')?.content,
ogImage: document.querySelector('meta[property="og:image"]')?.content,
canonicalUrl: document.querySelector('link[rel="canonical"]')?.href,
wordCount: countWords(document.body.innerText),
};
}
function countWords(text) {
return text.trim().split(/\s+/).filter(Boolean).length;
}describe('page metadata extraction', () => {
beforeEach(() => {
document.title = 'Test Article';
document.head.innerHTML = `
<meta name="description" content="Article description">
<meta property="og:image" content="https://example.com/image.jpg">
<link rel="canonical" href="https://example.com/article">
`;
document.body.innerHTML = '<p>Hello world this is test content</p>';
});
it('extracts page title', () => {
const metadata = extractPageMetadata();
expect(metadata.title).toBe('Test Article');
});
it('extracts meta description', () => {
const metadata = extractPageMetadata();
expect(metadata.description).toBe('Article description');
});
it('handles missing meta tags gracefully', () => {
document.head.innerHTML = ''; // No meta tags
const metadata = extractPageMetadata();
expect(metadata.description).toBeUndefined();
expect(metadata.ogImage).toBeUndefined();
});
it('counts words in page body', () => {
document.body.innerHTML = '<p>One two three four five</p>';
const metadata = extractPageMetadata();
expect(metadata.wordCount).toBe(5);
});
});Testing Message Passing
Content scripts communicate with the background via the Chrome messaging API:
// content-scripts/highlighter.js
export async function highlightAndSaveSelection() {
const selection = window.getSelection();
if (!selection || selection.isCollapsed) return null;
const text = selection.toString().trim();
if (!text) return null;
// Send to background for processing/storage
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage(
{ type: 'SAVE_HIGHLIGHT', text, url: window.location.href },
(response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
} else if (response.success) {
resolve(response.highlightId);
} else {
reject(new Error(response.error));
}
}
);
});
}describe('highlight and save', () => {
beforeEach(() => {
jest.clearAllMocks();
// Mock window.getSelection
Object.defineProperty(window, 'getSelection', {
value: jest.fn(),
writable: true,
});
});
it('sends selected text to background', async () => {
// Mock selection
window.getSelection.mockReturnValue({
isCollapsed: false,
toString: () => 'Selected text',
});
chrome.runtime.sendMessage.mockImplementation((msg, callback) => {
callback({ success: true, highlightId: 'h-123' });
});
chrome.runtime.lastError = undefined;
const highlightId = await highlightAndSaveSelection();
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'SAVE_HIGHLIGHT',
text: 'Selected text',
}),
expect.any(Function)
);
expect(highlightId).toBe('h-123');
});
it('returns null when nothing is selected', async () => {
window.getSelection.mockReturnValue({ isCollapsed: true });
const result = await highlightAndSaveSelection();
expect(result).toBeNull();
expect(chrome.runtime.sendMessage).not.toHaveBeenCalled();
});
it('rejects on Chrome runtime error', async () => {
window.getSelection.mockReturnValue({
isCollapsed: false,
toString: () => 'Text',
});
chrome.runtime.sendMessage.mockImplementation((msg, callback) => {
chrome.runtime.lastError = { message: 'Extension context invalidated' };
callback(undefined);
});
await expect(highlightAndSaveSelection())
.rejects.toThrow('Extension context invalidated');
});
});Integration Testing Content Scripts in Real Pages
Unit tests verify logic. Integration tests verify that the content script actually works in a real browser context. Use Playwright:
// tests/integration/content-script.spec.js
import { test, expect, chromium } from '@playwright/test';
import path from 'path';
test.describe('content script integration', () => {
let browser;
let context;
test.beforeAll(async () => {
const extensionPath = path.join(__dirname, '../../dist'); // built extension
browser = await chromium.launchPersistentContext('', {
headless: false, // Extensions require non-headless in older Playwright
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`,
],
});
context = browser.contexts()[0];
});
test.afterAll(() => browser.close());
test('tooltip appears on hover over annotated element', async () => {
const page = await context.newPage();
await page.goto('https://example.com');
// Find an element the extension would annotate
const targetElement = page.locator('[data-ext-annotate]').first();
await targetElement.hover();
// Wait for the tooltip to appear
const tooltip = page.locator('#ext-tooltip');
await expect(tooltip).toBeVisible({ timeout: 2000 });
await expect(tooltip).not.toBeEmpty();
});
test('highlight save works on real page', async () => {
const page = await context.newPage();
await page.goto('https://example.com');
// Select some text
const paragraph = page.locator('p').first();
await paragraph.selectText();
// Trigger the highlight action (e.g., keyboard shortcut or context menu)
await page.keyboard.press('Control+Shift+H');
// Verify feedback (e.g., visual indicator appears)
await expect(page.locator('.ext-highlight-saved')).toBeVisible();
});
test('content script does not break page functionality', async () => {
const page = await context.newPage();
await page.goto('https://example.com/form');
// Fill out and submit form — extension should not interfere
await page.fill('input[name="email"]', 'test@test.com');
await page.click('[type="submit"]');
// Page should have navigated/responded normally
await expect(page).not.toHaveURL(/error/);
});
});Testing Style Isolation
Content scripts that inject CSS must not break the host page's styles:
describe('style isolation', () => {
it('injects styles with extension-specific class prefix', () => {
// All injected elements should use 'ext-' prefix to avoid collisions
const injectedElements = document.querySelectorAll('[class]');
injectedElements.forEach(el => {
const classes = Array.from(el.classList);
// Any classes the extension adds should be prefixed
const extensionClasses = classes.filter(c => c.startsWith('ext-'));
const unprefixedExtClasses = classes.filter(c =>
['tooltip', 'highlight', 'sidebar', 'overlay'].some(name => c === name)
);
expect(unprefixedExtClasses).toHaveLength(0);
});
});
it('uses shadow DOM for complex UI injection', async () => {
injectExtensionSidebar();
const shadowHost = document.getElementById('ext-sidebar-host');
expect(shadowHost).not.toBeNull();
// Content should be in shadow DOM, not directly in page
expect(shadowHost.shadowRoot).not.toBeNull();
expect(document.querySelector('.ext-sidebar-content')).toBeNull(); // Not in page DOM
});
});Summary
Testing browser extension content scripts requires two layers:
Unit tests (Jest + jsdom): Test DOM manipulation functions, message passing, and page content extraction in isolation. Fast, no browser required.
Integration tests (Playwright): Load the real extension in a real browser and test that content scripts work correctly on real pages. Slower but catches environment-specific bugs.
The key unit test patterns are: set up DOM via innerHTML, mock chrome.runtime.sendMessage, and mock window.getSelection for text operations. For integration tests, use Playwright's --load-extension flag to load the actual built extension.