Firefox Extension Testing: Cross-Browser Compatibility Strategies
Building a browser extension that works in both Chrome and Firefox sounds straightforward — both use the WebExtensions API. In practice, there are enough API differences, manifest differences, and behavioral quirks to make cross-browser extension testing its own discipline.
This guide focuses on Firefox-specific testing: what's different from Chrome, what tools to use, and how to structure your tests for cross-browser coverage.
Chrome vs Firefox: Key API Differences
Before testing, you need to understand where Firefox and Chrome diverge.
Promise-based vs callback-based APIs: Firefox's WebExtensions API is natively Promise-based. Chrome's is callback-based (though Chrome has been adding Promise support). A Firefox extension written with await browser.storage.local.get() needs to be adapted for Chrome or run through a polyfill like webextension-polyfill.
browser vs chrome namespace: Firefox uses browser.*, Chrome uses chrome.*. Extensions targeting both should use browser.* with the polyfill, or check typeof browser !== 'undefined' at runtime.
Manifest differences:
- Chrome MV3:
"background": { "service_worker": "background.js" } - Firefox:
"background": { "scripts": ["background.js"] }(Firefox supports MV3 but with some differences; MV2 is still widely used in Firefox) - Firefox requires
"browser_specific_settings"for AMO submission
Content Security Policy: Firefox enforces CSP more strictly in some cases. Extensions that use eval or dynamic code execution may work in Chrome but fail in Firefox.
Container tab support: Firefox has a container tab concept (Multi-Account Containers) with a cookieStoreId on tabs. If your extension manages cookies or tabs, this is Firefox-only functionality to test.
Sidebar support: Firefox supports browser sidebars natively ("sidebar_action"). Chrome doesn't have this. If you support sidebars in Firefox, you need Firefox-specific tests.
Setting Up Firefox Testing with web-ext
Mozilla provides web-ext, the official CLI for Firefox extension development and testing:
npm install --save-dev web-extRunning your extension in Firefox for manual testing:
web-ext run --source-dir ./src --firefox=firefoxRunning the linting tool that catches Firefox-specific issues:
web-ext lint --source-dir ./srcweb-ext lint checks for:
- Deprecated manifest keys
- APIs not supported in Firefox
- Missing
browser_specific_settings.gecko.id - Policy violations that would reject the extension from AMO
Run this in CI as a required check:
- name: Lint extension for Firefox
run: npx web-ext lint --source-dir ./srcTesting with the Polyfill
If your extension targets both Chrome and Firefox, use webextension-polyfill:
// background.js
import browser from 'webextension-polyfill';
// Now you can use browser.* API with Promises in both Chrome and Firefox
const settings = await browser.storage.sync.get(['theme', 'language']);Testing this setup — ensure the polyfill works correctly in your unit tests:
// jest.setup.js
// Mock the polyfill — in tests we use the polyfill's API
jest.mock('webextension-polyfill', () => ({
storage: {
sync: {
get: jest.fn().mockResolvedValue({}),
set: jest.fn().mockResolvedValue(undefined),
},
local: {
get: jest.fn().mockResolvedValue({}),
set: jest.fn().mockResolvedValue(undefined),
},
},
runtime: {
sendMessage: jest.fn().mockResolvedValue({}),
onMessage: {
addListener: jest.fn(),
removeListener: jest.fn(),
},
getURL: jest.fn((path) => `moz-extension://test-id/${path}`),
lastError: undefined,
},
tabs: {
query: jest.fn().mockResolvedValue([]),
create: jest.fn().mockResolvedValue({}),
sendMessage: jest.fn().mockResolvedValue({}),
},
}));End-to-End Testing Firefox Extensions with Playwright
Playwright supports Firefox extension testing since version 1.18:
// playwright.config.js
import { defineConfig } from '@playwright/test';
import path from 'path';
const EXTENSION_PATH = path.join(__dirname, 'dist');
export default defineConfig({
projects: [
{
name: 'chrome',
use: {
launchOptions: {
args: [
`--disable-extensions-except=${EXTENSION_PATH}`,
`--load-extension=${EXTENSION_PATH}`,
],
},
},
},
{
name: 'firefox',
use: {
browserName: 'firefox',
launchOptions: {
firefoxUserPrefs: {
// Required for extension testing in Firefox
'xpinstall.signatures.required': false,
},
args: ['-profile', '/tmp/firefox-test-profile'],
},
},
},
],
});Loading the extension in Firefox requires a different approach than Chrome:
// tests/e2e/firefox-setup.js
import { firefox } from '@playwright/test';
import path from 'path';
export async function launchFirefoxWithExtension() {
const browser = await firefox.launch({
firefoxUserPrefs: {
'xpinstall.signatures.required': false,
},
});
const context = await browser.newContext();
// Firefox requires installing the extension via the addon manager
const extensionPath = path.join(__dirname, '../../dist');
await context.route('**/*', route => route.continue());
// Use web-ext API to load the extension
// Or use Firefox's about:debugging approach
const page = await context.newPage();
await page.goto('about:debugging#/runtime/this-firefox');
await page.click('[data-l10n-id="about-debugging-tmp-extension-install-button"]');
// File dialog handling is complex — see web-ext for alternatives
return { browser, context };
}Practical note: Firefox extension loading in Playwright is more complex than Chrome. For most teams, use web-ext run for Firefox-specific interactive testing and Playwright for Chrome E2E, then test Firefox manually or with selenium/geckodriver.
Using geckodriver and Selenium for Firefox
For more reliable Firefox extension testing in CI, Selenium with geckodriver is the established approach:
// tests/firefox/extension.test.js (using selenium-webdriver)
const { Builder, By, until } = require('selenium-webdriver');
const firefox = require('selenium-webdriver/firefox');
const path = require('path');
describe('Firefox extension', () => {
let driver;
beforeAll(async () => {
const extensionPath = path.join(__dirname, '../../dist.xpi'); // Firefox requires .xpi
const options = new firefox.Options();
options.addExtensions(extensionPath);
options.setPreference('xpinstall.signatures.required', false);
options.headless(); // Headless Firefox works for extensions
driver = await new Builder()
.forBrowser('firefox')
.setFirefoxOptions(options)
.build();
});
afterAll(() => driver.quit());
test('extension popup opens', async () => {
await driver.get('about:blank');
// Firefox extensions are accessible via moz-extension:// URL
// Get the extension ID first
const extensionId = await getFirefoxExtensionId(driver);
await driver.get(`moz-extension://${extensionId}/popup.html`);
const header = await driver.wait(
until.elementLocated(By.css('[data-testid="popup-header"]')),
5000
);
expect(await header.isDisplayed()).toBe(true);
});
});
async function getFirefoxExtensionId(driver) {
// Navigate to about:debugging and parse the extension ID
await driver.get('about:debugging#/runtime/this-firefox');
const idElement = await driver.wait(
until.elementLocated(By.css('.debug-target-item__detail-target-full-id')),
3000
);
return (await idElement.getText()).replace('ID: ', '');
}To create the .xpi file:
web-ext build --source-dir ./src --artifacts-dir ./dist
# Creates dist/extension.xpiCross-Browser Test Structure
Organize tests to run the same scenarios across both browsers:
// tests/e2e/scenarios.js — browser-agnostic test scenarios
export const extensionScenarios = {
'popup shows correct item count': async (page, extensionUrl) => {
await page.goto(`${extensionUrl}/popup.html`);
await expect(page.locator('[data-testid="item-count"]')).toBeVisible();
},
'settings are saved and retrieved': async (page, extensionUrl) => {
await page.goto(`${extensionUrl}/options.html`);
await page.selectOption('[data-testid="theme"]', 'dark');
await page.click('[data-testid="save"]');
await page.reload();
await expect(page.locator('[data-testid="theme"]')).toHaveValue('dark');
},
};
// tests/e2e/chrome.spec.js
import { extensionScenarios } from './scenarios';
for (const [name, scenario] of Object.entries(extensionScenarios)) {
test(`Chrome: ${name}`, async ({ browser }) => {
const extensionId = await getChromeExtensionId(browser);
const page = await browser.newPage();
await scenario(page, `chrome-extension://${extensionId}`);
});
}
// tests/e2e/firefox.spec.js
for (const [name, scenario] of Object.entries(extensionScenarios)) {
test(`Firefox: ${name}`, async ({ browser }) => {
const extensionId = await getFirefoxExtensionId(browser);
const page = await browser.newPage();
await scenario(page, `moz-extension://${extensionId}`);
});
}Firefox-Specific Features to Test
Container tabs (if you use browser.contextualIdentities):
test('extension works with Firefox container tabs', async () => {
// Create a container tab
const container = await browser.contextualIdentities.create({
name: 'Work',
color: 'blue',
icon: 'briefcase',
});
const tab = await browser.tabs.create({
url: 'https://example.com',
cookieStoreId: container.cookieStoreId,
});
// Verify extension handles container tabs correctly
const result = await browser.runtime.sendMessage({
type: 'GET_TAB_INFO',
tabId: tab.id,
});
expect(result.cookieStoreId).toBe(container.cookieStoreId);
});Sidebar (if you implement sidebar_action):
test('sidebar opens correctly in Firefox', async () => {
await driver.get('https://example.com');
// Click the sidebar button (extension action)
const action = driver.wait(
until.elementLocated(By.css('.sidebar-action')),
3000
);
await action.click();
// Switch to sidebar frame
await driver.switchTo().frame('sidebar-frame');
const sidebarContent = await driver.wait(
until.elementLocated(By.css('[data-testid="sidebar-content"]')),
3000
);
expect(await sidebarContent.isDisplayed()).toBe(true);
});Firefox Manifest Validation
Firefox has stricter manifest validation than Chrome. Always lint your manifest:
// tests/manifest.test.js
const manifest = require('../src/manifest.json');
describe('manifest validation', () => {
it('has required browser_specific_settings for Firefox', () => {
expect(manifest.browser_specific_settings?.gecko?.id).toBeDefined();
expect(manifest.browser_specific_settings.gecko.id).toMatch(
/^[a-z0-9._-]+@[a-z0-9._-]+\.[a-z]+$/
);
});
it('specifies minimum Firefox version', () => {
expect(manifest.browser_specific_settings?.gecko?.strict_min_version).toBeDefined();
});
it('does not use Chrome-only APIs in permissions', () => {
const chromeOnlyPermissions = ['desktopCapture', 'platformKeys'];
const permissions = manifest.permissions || [];
const chromeOnlyUsed = permissions.filter(p => chromeOnlyPermissions.includes(p));
expect(chromeOnlyUsed).toHaveLength(0);
});
});Summary
Firefox extension testing requires:
web-ext lintin CI to catch Firefox-specific manifest and API issues- Promise-based API usage via
webextension-polyfill - Firefox-specific E2E setup using geckodriver + selenium or Playwright's Firefox support
- Separate test runs for Firefox-only features (containers, sidebar)
- Cross-browser test scenarios shared between Chrome and Firefox test files
The most common cross-browser bugs are callback vs Promise API usage, chrome.* vs browser.* namespace issues, and manifest configuration differences. Unit tests with proper mocks catch most of these; a Firefox E2E run catches the rest.