Chrome Extension Testing Guide: Jest, Puppeteer, and chrome-mock

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 APIschrome.tabs, chrome.storage, chrome.runtime are 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 key field in manifest.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 puppeteer

Configure 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 scenarios

Run 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 Linux

For 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:

  1. Jest + jest-chrome for unit testing business logic and storage operations
  2. Puppeteer for integration testing popup UI, content scripts, and message passing
  3. 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.

Read more