Testing Manifest V3 Chrome Extensions: Service Workers, Content Scripts, and Popup

Testing Manifest V3 Chrome Extensions: Service Workers, Content Scripts, and Popup

Manifest V3 replaces background pages with service workers and removes chrome.webRequest blocking in favor of declarativeNetRequest. Test service workers by communicating via chrome.runtime.sendMessage from pages or content scripts. Service workers are ephemeral — they can be suspended between events, so your tests must account for startup latency. Use Puppeteer or Playwright with persistent context and --load-extension to run integration tests.

Key Takeaways

Service workers are not background pages. They don't persist between events. Design tests to send a message, get a response, and not assume the worker stays alive between assertions.

declarativeNetRequest replaces webRequest blocking. You can't intercept requests programmatically in MV3. Test your rule sets using chrome.declarativeNetRequest.testMatchOutcome() in integration tests.

Content scripts still work the same way. The MV3 migration primarily affects background scripts. Your content script tests from MV2 will mostly still work.

Use chrome.scripting instead of tabs.executeScript. MV3 requires chrome.scripting.executeScript() instead of the old chrome.tabs.executeScript(). This affects both your extension code and tests.

The offscreen API handles audio/DOM work. Service workers can't access the DOM. Use the Offscreen API for tasks that require it, and test the offscreen document separately.

Manifest V3 is the current extension manifest format for Chrome, and it brings significant architectural changes that affect how you test extensions. The biggest change: background pages are gone, replaced by ephemeral service workers. This guide covers what changes in your test strategy and how to test each MV3 component properly.

What Changed from MV2 to MV3

Feature MV2 MV3
Background Persistent background page Ephemeral service worker
Request interception chrome.webRequest (blocking) declarativeNetRequest
Script injection chrome.tabs.executeScript() chrome.scripting.executeScript()
DOM access in background Yes (background page) No (use Offscreen API)
Remote code Allowed Blocked

Each of these changes requires a different testing approach.

Testing Service Workers

The Core Challenge

Service workers terminate when idle. They start on demand (when an event fires) and can shut down within seconds of becoming inactive. This means:

  • You can't hold a reference to the service worker and call it later
  • Tests must communicate through events and messages, not direct function calls
  • Each test interaction may restart the service worker

Setting Up for Service Worker Testing

// test/sw.setup.js
const { chromium } = require('@playwright/test');
const path = require('path');

async function createExtensionContext() {
  const extensionPath = path.resolve(__dirname, '../dist');

  const context = await chromium.launchPersistentContext('', {
    headless: false,
    args: [
      `--disable-extensions-except=${extensionPath}`,
      `--load-extension=${extensionPath}`,
    ],
  });

  // Wait for service worker to register
  await context.waitForEvent('serviceworker');
  const [serviceWorker] = context.serviceWorkers();
  const extensionId = new URL(serviceWorker.url()).hostname;

  return { context, serviceWorker, extensionId };
}

module.exports = { createExtensionContext };

Sending Messages to the Service Worker

The reliable way to communicate with a service worker is via chrome.runtime.sendMessage from a page:

// test/serviceWorker.test.js
const { createExtensionContext } = require('./sw.setup');

describe('Service Worker', () => {
  let context, serviceWorker, extensionId, page;

  beforeAll(async () => {
    ({ context, serviceWorker, extensionId } = await createExtensionContext());
    page = await context.newPage();
    await page.goto('about:blank');
  });

  afterAll(() => context.close());

  test('responds to GET_CONFIG message', async () => {
    const response = await page.evaluate(() =>
      new Promise((resolve, reject) => {
        chrome.runtime.sendMessage({ type: 'GET_CONFIG' }, response => {
          if (chrome.runtime.lastError) {
            reject(new Error(chrome.runtime.lastError.message));
          } else {
            resolve(response);
          }
        });
      })
    );

    expect(response).toHaveProperty('version');
    expect(response).toHaveProperty('rules');
  });

  test('processes SAVE_RULE message', async () => {
    const rule = { pattern: 'example.com', action: 'block' };

    const result = await page.evaluate((rule) =>
      new Promise(resolve =>
        chrome.runtime.sendMessage({ type: 'SAVE_RULE', rule }, resolve)
      ), rule
    );

    expect(result.success).toBe(true);

    // Verify rule was stored
    const config = await page.evaluate(() =>
      new Promise(resolve =>
        chrome.runtime.sendMessage({ type: 'GET_CONFIG' }, resolve)
      )
    );

    expect(config.rules).toContainEqual(rule);
  });
});

Testing Alarm-Based Logic

Alarms are the MV3 alternative to setInterval for recurring background tasks. Test alarm handlers by triggering them directly:

test('alarm handler refreshes data', async () => {
  // Trigger the alarm via the service worker evaluator
  await serviceWorker.evaluate(() => {
    // Simulate alarm firing
    chrome.alarms.onAlarm.dispatch({ name: 'refresh-data' });
  });

  // Allow async operations to complete
  await page.waitForTimeout(500);

  // Verify the side effect (storage update)
  const data = await page.evaluate(() =>
    new Promise(resolve =>
      chrome.storage.local.get('lastRefresh', d => resolve(d.lastRefresh))
    )
  );

  expect(data).toBeDefined();
  expect(new Date(data).getTime()).toBeCloseTo(Date.now(), -3);
});

Testing declarativeNetRequest

MV3 uses declarativeNetRequest for request blocking and redirection. You can't intercept requests dynamically, but you can test your rules using the testMatchOutcome API:

// test/networkRules.test.js
describe('Network Rules', () => {
  test('blocks ad domain requests', async () => {
    const outcome = await page.evaluate(() =>
      chrome.declarativeNetRequest.testMatchOutcome({
        url: 'https://ads.doubleclick.net/tracker.js',
        type: 'script',
        initiator: 'https://example.com',
        method: 'get',
        tabId: chrome.tabs.TAB_ID_NONE,
      })
    );

    expect(outcome.matchedRule).toBeDefined();
    expect(outcome.matchedRule.ruleId).toBeGreaterThan(0);
  });

  test('allows first-party resources', async () => {
    const outcome = await page.evaluate(() =>
      chrome.declarativeNetRequest.testMatchOutcome({
        url: 'https://example.com/styles.css',
        type: 'stylesheet',
        initiator: 'https://example.com',
        method: 'get',
        tabId: chrome.tabs.TAB_ID_NONE,
      })
    );

    expect(outcome.matchedRule).toBeUndefined();
  });

  test('redirects tracking pixels', async () => {
    const outcome = await page.evaluate(() =>
      chrome.declarativeNetRequest.testMatchOutcome({
        url: 'https://tracker.example.com/pixel.gif',
        type: 'image',
        initiator: 'https://news.com',
        method: 'get',
        tabId: chrome.tabs.TAB_ID_NONE,
      })
    );

    expect(outcome.matchedRule?.action?.type).toBe('redirect');
  });
});

Testing Content Scripts in MV3

Content scripts work the same in MV3, but script injection via the extension changed:

// In MV3, use chrome.scripting.executeScript()
// NOT chrome.tabs.executeScript() (removed in MV3)

// service-worker.js
chrome.action.onClicked.addListener(async (tab) => {
  await chrome.scripting.executeScript({
    target: { tabId: tab.id },
    func: () => {
      document.body.style.backgroundColor = 'yellow';
    },
  });
});

Test this pattern by clicking the action button and verifying the injected effect:

// test/scriptInjection.test.js
test('action click injects highlight script', async () => {
  const targetPage = await context.newPage();
  await targetPage.goto('https://example.com');

  // Simulate clicking the extension action
  await page.evaluate(async (tabUrl) => {
    const [tab] = await chrome.tabs.query({ url: tabUrl });
    chrome.action.onClicked.dispatch(tab);
  }, 'https://example.com/*');

  await targetPage.waitForFunction(
    () => document.body.style.backgroundColor === 'yellow',
    { timeout: 3000 }
  );

  const bgColor = await targetPage.evaluate(
    () => document.body.style.backgroundColor
  );
  expect(bgColor).toBe('yellow');

  await targetPage.close();
});

Testing the Offscreen API

MV3 service workers can't access the DOM. The Offscreen API creates a hidden document for DOM operations:

// offscreen.js
chrome.runtime.onMessage.addListener(handleMessages);

function handleMessages(message) {
  if (message.type === 'PARSE_HTML') {
    const parser = new DOMParser();
    const doc = parser.parseFromString(message.html, 'text/html');
    const title = doc.querySelector('title')?.textContent ?? '';

    chrome.runtime.sendMessage({
      type: 'PARSE_RESULT',
      title,
    });
  }
}

Test offscreen documents by sending messages through the service worker:

test('offscreen document parses HTML', async () => {
  // Service worker creates offscreen document and sends message
  const result = await page.evaluate(() =>
    new Promise(resolve => {
      chrome.runtime.onMessage.addListener(function handler(msg) {
        if (msg.type === 'PARSE_RESULT') {
          chrome.runtime.onMessage.removeListener(handler);
          resolve(msg);
        }
      });

      chrome.runtime.sendMessage({
        type: 'PARSE_HTML',
        html: '<html><head><title>Test Page</title></head></html>',
      });
    })
  );

  expect(result.title).toBe('Test Page');
});

Testing the Popup

Popup testing is unchanged from MV2. Navigate directly to the popup URL:

// test/popup.test.js
describe('Popup', () => {
  let popupPage;

  beforeEach(async () => {
    popupPage = await context.newPage();
    await popupPage.goto(`chrome-extension://${extensionId}/popup.html`);
  });

  afterEach(() => popupPage.close());

  test('shows rule count from storage', async () => {
    // Set up storage state
    await popupPage.evaluate(() =>
      new Promise(resolve =>
        chrome.storage.local.set({
          rules: [{ pattern: 'ads.example.com' }, { pattern: 'tracker.io' }]
        }, resolve)
      )
    );

    await popupPage.reload();

    const count = await popupPage.$eval('#rule-count', el => el.textContent);
    expect(count).toBe('2 rules active');
  });

  test('add rule form updates storage', async () => {
    await popupPage.fill('#new-rule-input', 'spamsite.com');
    await popupPage.click('#add-rule-btn');

    const rules = await popupPage.evaluate(() =>
      new Promise(resolve =>
        chrome.storage.local.get('rules', d => resolve(d.rules))
      )
    );

    expect(rules.some(r => r.pattern === 'spamsite.com')).toBe(true);
  });
});

Unit Testing with Jest

For business logic that doesn't require a browser, jest-chrome works for MV3 APIs:

// jest.setup.js
const { chrome } = require('jest-chrome');

// Add MV3-specific mocks
chrome.scripting = {
  executeScript: jest.fn().mockResolvedValue([{ result: undefined }]),
  insertCSS: jest.fn().mockResolvedValue(undefined),
};

chrome.declarativeNetRequest = {
  updateDynamicRules: jest.fn().mockResolvedValue(undefined),
  getDynamicRules: jest.fn().mockResolvedValue([]),
  testMatchOutcome: jest.fn().mockResolvedValue({ matchedRule: undefined }),
};

chrome.offscreen = {
  createDocument: jest.fn().mockResolvedValue(undefined),
  closeDocument: jest.fn().mockResolvedValue(undefined),
  hasDocument: jest.fn().mockResolvedValue(false),
};

Object.assign(global, { chrome });

CI Configuration

name: MV3 Extension Tests

on: [push]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install deps
        run: npm ci

      - name: Install Playwright
        run: npx playwright install chromium --with-deps

      - name: Build extension
        run: npm run build

      - name: Unit tests
        run: npm test -- --testPathPattern=unit

      - name: Integration tests
        run: npx playwright test
        env:
          DISPLAY: ':99'

Common MV3 Testing Pitfalls

Don't call service worker methods directly. In Puppeteer/Playwright, serviceWorker.evaluate() lets you run code inside the worker, but the worker can terminate. Prefer messaging from a page.

Account for service worker startup. If a test sends a message immediately after the browser loads, the service worker may not be ready. Add context.waitForEvent('serviceworker') before proceeding.

Test declarativeNetRequest rules in code review. Static rule JSON is hard to test end-to-end. Use chrome.declarativeNetRequest.testMatchOutcome() in integration tests to verify rules match expected URLs.

chrome.scripting requires explicit permissions. Ensure your manifest.json includes "scripting" in permissions, or executeScript calls will silently fail.

Continuous Monitoring with HelpMeTest

MV3 extensions that automate user-facing behavior (highlight keywords, fill forms, block ads) need ongoing monitoring on real pages. HelpMeTest runs your extension test scenarios on a schedule — write the behavior once in plain English, and get alerts when the extension breaks on live sites. Priced at $100/month with no infrastructure to manage.

Summary

Testing Manifest V3 extensions requires adjusting for the service worker model:

  1. Service workers: test via messages, not direct calls; account for ephemeral lifecycle
  2. declarativeNetRequest: use testMatchOutcome API to validate rules
  3. Popup: unchanged from MV2 — navigate directly to the popup URL
  4. Content scripts: unchanged from MV2 — navigate to matching pages
  5. Offscreen API: test via message round-trips through the service worker
  6. Unit tests: extend jest-chrome with MV3 API mocks

The core insight: MV3 is more event-driven and message-based than MV2. Design your tests around messages and storage state, not direct function calls.

Read more