Chrome Extension Testing Strategies — Developer Guide

17 min read

Chrome Extension Testing Strategies

A comprehensive guide to testing Chrome Extensions (MV3) across all layers: unit tests, integration tests, and end-to-end tests.

Overview

Chrome extensions operate across multiple execution contexts—service workers, content scripts, popup pages, and options pages—each with unique testing challenges. A robust testing strategy covers all these contexts while handling Chrome-specific APIs.

Testing Pyramid for Extensions

        ┌─────────────┐
        │  Manual     │  ← Load unpacked, verify UI
        │  Testing    │
       ┌┴─────────────┴┐
       │  E2E Tests    │  ← Puppeteer/Playwright
       │  (Browser)    │
      ┌┴───────────────┴┐
      │  Integration   │  ← Mocked Chrome APIs
      │  Tests         │
     ┌┴────────────────┴┐
     │  Unit Tests      │  ← Pure logic
     │  (Fast, isolated)│
     └──────────────────┘

Unit Testing

Unit tests verify pure business logic without Chrome API dependencies. Extract logic into separate modules that can be tested independently.

Test Framework Setup

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'node',
    include: ['src/**/*.test.ts'],
    setupFiles: ['./test/setup.ts'],
  },
});

Testing Pure Functions

// src/utils/url-parser.ts
export function extractDomain(url: string): string {
  try {
    return new URL(url).hostname;
  } catch {
    return '';
  }
}

export function isValidExtensionId(id: string): boolean {
  return /^[a-z]{32}$/.test(id);
}

// src/utils/url-parser.test.ts
import { describe, it, expect } from 'vitest';
import { extractDomain, isValidExtensionId } from './url-parser';

describe('extractDomain', () => {
  it('extracts hostname from URL', () => {
    expect(extractDomain('https://example.com/path')).toBe('example.com');
  });

  it('handles invalid URLs', () => {
    expect(extractDomain('not-a-url')).toBe('');
  });
});

describe('isValidExtensionId', () => {
  it('validates 32-character lowercase IDs', () => {
    expect(isValidExtensionId('abcdefghijklmnopqrstuvwxyz012345')).toBe(true);
    expect(isValidExtensionId('ABCDEF')).toBe(false);
  });
});

Integration Testing

Integration tests verify interactions between extension components with mocked Chrome APIs.

Mocking Chrome Storage

// test/__mocks__/chrome-storage.ts
export const createMockStorage = () => {
  const store: Record<string, unknown> = {};

  return {
    get: vi.fn((keys?: string | string[]) => {
      if (!keys) return Promise.resolve({ ...store });
      const result: Record<string, unknown> = {};
      const keyArray = Array.isArray(keys) ? keys : [keys];
      keyArray.forEach(k => { if (k in store) result[k] = store[k]; });
      return Promise.resolve(result);
    }),
    set: vi.fn((items: Record<string, unknown>) => {
      Object.assign(store, items);
      return Promise.resolve();
    }),
    remove: vi.fn((keys: string | string[]) => {
      const keyArray = Array.isArray(keys) ? keys : [keys];
      keyArray.forEach(k => delete store[k]);
      return Promise.resolve();
    }),
    clear: vi.fn(() => {
      Object.keys(store).forEach(k => delete store[k]);
      return Promise.resolve();
    }),
    getBytesInUse: vi.fn(() => Promise.resolve(0)),
  };
};

// test/setup.ts
import { vi } from 'vitest';

const mockStorage = createMockStorage();

vi.stubGlobal('chrome', {
  storage: {
    local: mockStorage,
    sync: mockStorage,
    onChanged: {
      addListener: vi.fn(),
    },
  },
  runtime: {
    lastError: null,
    getURL: (path: string) => `chrome-extension://mock-id/${path}`,
    sendMessage: vi.fn(),
    onMessage: { addListener: vi.fn() },
    onInstalled: { addListener: vi.fn() },
  },
});

Testing Message Passing

// src/background/message-handler.ts
export function handleMessage(
  message: { type: string; payload?: unknown },
  sender: chrome.runtime.MessageSender
): Promise<{ success: boolean; data?: unknown }> {
  switch (message.type) {
    case 'GET_DATA':
      return Promise.resolve({ success: true, data: { key: 'value' } });
    case 'SET_DATA':
      return chrome.storage.local.set(message.payload as Record<string, unknown>)
        .then(() => ({ success: true }));
    default:
      return Promise.resolve({ success: false, data: 'Unknown message type' });
  }
}

// src/background/message-handler.test.ts
import { describe, it, expect, vi } from 'vitest';
import { handleMessage } from './message-handler';

describe('handleMessage', () => {
  it('returns data for GET_DATA messages', async () => {
    const result = await handleMessage({ type: 'GET_DATA' }, { id: '1' });
    expect(result.success).toBe(true);
    expect(result.data).toEqual({ key: 'value' });
  });

  it('stores data for SET_DATA messages', async () => {
    const result = await handleMessage(
      { type: 'SET_DATA', payload: { theme: 'dark' } },
      { id: '1' }
    );
    expect(chrome.storage.local.set).toHaveBeenCalledWith({ theme: 'dark' });
    expect(result.success).toBe(true);
  });
});

End-to-End Testing

E2E tests run the extension in a real Chrome browser with Puppeteer or Playwright.

Puppeteer Setup

// test/e2e/puppeteer-extension.ts
import puppeteer, { Browser, Page } from 'puppeteer';
import path from 'path';

export async function createExtensionBrowser(
  extensionPath: string
): Promise<{ browser: Browser; extId: string }> {
  const browser = await puppeteer.launch({
    headless: false,
    args: [
      `--disable-extensions-except=${extensionPath}`,
      `--load-extension=${extensionPath}`,
      '--no-sandbox',
    ],
  });

  // Get extension ID
  const targets = await browser.targets();
  const extTarget = targets.find(t => t.type() === 'service_worker');
  const extId = extTarget?.url().match(/chrome-extension:\/\/([^/]+)/)?.[1] || '';

  return { browser, extId };
}

export async function runExtensionE2E() {
  const extensionPath = path.resolve(__dirname, '../../dist');
  const { browser, extId } = await createExtensionBrowser(extensionPath);

  try {
    // Test popup
    const popup = await browser.newPage();
    await popup.goto(`chrome-extension://${extId}/popup.html`);

    const button = await popup.$('#action-btn');
    await button?.click();

    const status = await popup.$eval('#status', el => el.textContent);
    expect(status).toBe('Active');

    // Test content script on real page
    const page = await browser.newPage();
    await page.goto('https://example.com');
    await page.waitForSelector('.injected-element');
    const text = await page.$eval('.injected-element', el => el.textContent);
    expect(text).toContain('Extension Active');
  } finally {
    await browser.close();
  }
}

Playwright Alternative

// test/e2e/playwright-extension.ts
import { chromium, BrowserContext } from '@playwright/test';

export async function createExtensionContext(
  extensionPath: string
): Promise<{ context: BrowserContext; extId: string }> {
  const context = await chromium.launchPersistentContext('', {
    headless: false,
    args: [`--load-extension=${extensionPath}`],
  });

  // Wait for service worker
  let [sw] = context.serviceWorkers();
  if (!sw) sw = await context.waitForEvent('serviceworker');
  const extId = sw.url().split('/')[2];

  return { context, extId };
}

Testing Service Workers

Service workers have unique lifecycle considerations—Chrome can terminate them after inactivity.

Testing Event Registration

// src/background/service-worker.ts
chrome.runtime.onInstalled.addListener((details) => {
  if (details.reason === 'install') {
    chrome.storage.local.set({ installedAt: Date.now() });
  }
});

chrome.alarms.create('periodic-sync', { periodInMinutes: 15 });

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === 'ping') sendResponse({ pong: true });
  return true;
});

// src/background/service-worker.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';

describe('Service Worker', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('registers all event listeners', () => {
    require('./service-worker');

    expect(chrome.runtime.onInstalled.addListener).toHaveBeenCalled();
    expect(chrome.alarms.create).toHaveBeenCalledWith('periodic-sync', {
      periodInMinutes: 15,
    });
    expect(chrome.runtime.onMessage.addListener).toHaveBeenCalled();
  });

  it('handles messages correctly', () => {
    const listener = chrome.runtime.onMessage.addListener.mock.calls[0][0];
    const sendResponse = vi.fn();

    listener({ type: 'ping' }, { tab: { id: 1 } }, sendResponse);

    expect(sendResponse).toHaveBeenCalledWith({ pong: true });
  });
});

Testing SW Persistence

// test/e2e/service-worker-lifecycle.test.ts
import puppeteer from 'puppeteer';

test('state persists after SW restart', async () => {
  const browser = await puppeteer.launch({
    args: [`--load-extension=${extensionPath}`],
  });

  // Set initial state via popup
  const page = await browser.newPage();
  await page.goto(`chrome-extension://${extId}/popup.html`);
  await page.fill('#input', 'test-value');
  await page.click('#save');

  // Terminate service worker
  const client = await page.target().createCDPSession();
  await client.send('ServiceWorker.stopAllWorkers');

  // Wait for SW to restart
  await page.waitForTimeout(2000);

  // Verify state persisted
  await page.reload();
  const value = await page.$eval('#display', el => el.textContent);
  expect(value).toBe('test-value');

  await browser.close();
});

Testing Content Scripts

Content scripts run in the context of web pages and interact with the DOM.

JSDOM for Unit Tests

// test/unit/content-script.test.ts
import { JSDOM } from 'jsdom';

const dom = new JSDOM(`
  <!DOCTYPE html>
  <div id="container"></div>
`, { url: 'https://example.com' });

global.document = dom.window.document;
global.window = dom.window as Window;

// Test content script logic
function initializeUI() {
  const container = document.getElementById('container');
  if (!container) return;

  container.innerHTML = '<button id="action">Click me</button>';
  container.classList.add('initialized');
}

describe('Content Script UI', () => {
  beforeEach(() => {
    document.getElementById('container')!.innerHTML = '';
  });

  it('injects UI elements', () => {
    initializeUI();
    expect(document.querySelector('#action')).toBeTruthy();
    expect(document.getElementById('container')?.classList.contains('initialized')).toBe(true);
  });
});

Puppeteer for Browser Tests

// test/e2e/content-script.test.ts
import puppeteer from 'puppeteer';

test('content script modifies page', async () => {
  const browser = await puppeteer.launch({
    args: [`--load-extension=${extensionPath}`],
  });

  const page = await browser.newPage();
  await page.goto('https://example.com');

  // Wait for content script injection
  await page.waitForSelector('.extension-injected', { timeout: 5000 });

  const isVisible = await page.$eval('.extension-injected',
    el => window.getComputedStyle(el).display !== 'none'
  );
  expect(isVisible).toBe(true);

  await browser.close();
});

Testing Popup and Options UI

Test popup and options pages as regular web pages with DOM interaction.

// test/e2e/popup.test.ts
import puppeteer from 'puppeteer';

test('popup interactions', async () => {
  const { browser, extId } = await createExtensionBrowser(distPath);
  const popup = await browser.newPage();

  await popup.goto(`chrome-extension://${extId}/popup.html`);

  // Test form input
  await popup.fill('#username', 'testuser');
  await popup.click('#save-btn');

  // Verify storage call
  expect(chrome.storage.local.set).toHaveBeenCalledWith(
    expect.objectContaining({ username: 'testuser' })
  );

  // Verify UI update
  const message = await popup.$eval('.message', el => el.textContent);
  expect(message).toContain('Saved');

  await browser.close();
});

CI/CD Integration

Automate testing in GitHub Actions with Chrome installed.

# .github/workflows/test.yml
name: Test Suite

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck
      - run: npm test -- --coverage

  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm run build

      - name: Install Playwright
        run: npx playwright install chromium

      - name: Run E2E tests
        run: npm run test:e2e
        env:
          CI: true

Cross-References

Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.