Comprehensive Chrome Extension Testing Guide — From Unit Tests to E2E

27 min read

Comprehensive Chrome Extension Testing Guide — From Unit Tests to E2E

Introduction

Testing Chrome extensions presents unique challenges that differ significantly from traditional web applications. Extensions operate across multiple execution contexts—including service workers, content scripts, popup pages, options pages, and side panels—each with its own isolated JavaScript environment. Additionally, extensions rely heavily on Chrome-specific APIs that aren’t available in standard browser contexts, making traditional testing approaches insufficient.

This comprehensive guide covers the complete testing ecosystem for Chrome extensions, from unit tests that validate business logic to end-to-end tests that verify the entire extension works correctly in a real browser. You’ll learn how to set up testing environments, mock Chrome APIs effectively, implement visual regression testing, and configure CI/CD pipelines that catch issues before they reach your users.

The testing pyramid for extensions differs slightly from web applications due to the complexity of extension contexts. While web apps might have a 70/20/10 split between unit, integration, and E2E tests, extensions typically benefit from a more balanced approach with heavier emphasis on integration testing because of the complex interactions between extension components.

Unit Testing Chrome Extension Code

Unit tests form the foundation of your testing strategy, validating that individual functions and modules work correctly in isolation. For Chrome extensions, unit tests are particularly valuable for testing utility functions, message handlers, data transformation logic, and any business logic that doesn’t directly depend on Chrome APIs.

Setting Up Jest for Extensions

Jest remains a popular choice for unit testing Chrome extensions, especially for projects that originated before Vitest gained widespread adoption. Setting up Jest requires configuring the test environment to handle browser-like APIs while excluding Chrome-specific globals.

// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/test/setup.js'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  transform: {
    '^.+\\.(ts|tsx)$': ['ts-jest', { useESM: true }],
  },
  testMatch: ['**/*.test.ts', '**/*.test.js'],
};

The setup file should initialize any global mocks needed across all tests:

// test/setup.js
// Mock Chrome APIs at the global level
global.chrome = {
  storage: {
    local: {
      get: jest.fn(),
      set: jest.fn(),
      remove: jest.fn(),
    },
    sync: {
      get: jest.fn(),
      set: jest.fn(),
    },
    onChanged: {
      addListener: jest.fn(),
      removeListener: jest.fn(),
    },
  },
  runtime: {
    lastError: null,
    id: 'test-extension-id',
    sendMessage: jest.fn(),
    onMessage: {
      addListener: jest.fn(),
      removeListener: jest.fn(),
    },
  },
  tabs: {
    query: jest.fn(),
    sendMessage: jest.fn(),
  },
};

Setting Up Vitest for Extensions

Vitest has become the preferred testing framework for modern Chrome extension projects due to its superior performance, native ESM support, and Vite integration. The configuration closely mirrors Jest but takes advantage of Vite’s capabilities.

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

export default defineConfig({
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./test/setup.ts'],
    include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
});

Vitest’s watch mode is particularly valuable during development, providing instant feedback as you modify your extension code. The --coverage flag with the v8 or istanbul provider generates coverage reports that highlight untested code paths.

Testing Pure Business Logic

The most effective unit tests focus on pure functions—code that takes inputs and produces outputs without side effects. In extension development, this includes URL parsing utilities, data transformers, message type validators, and storage schema definitions.

// src/utils/url-parser.ts
export function parseExtensionUrl(url: string): { domain: string; path: string } | null {
  try {
    const parsed = new URL(url);
    return {
      domain: parsed.hostname,
      path: parsed.pathname,
    };
  } catch {
    return null;
  }
}

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

describe('parseExtensionUrl', () => {
  it('parses valid URLs correctly', () => {
    const result = parseExtensionUrl('https://example.com/api/data');
    expect(result).toEqual({ domain: 'example.com', path: '/api/data' });
  });

  it('returns null for invalid URLs', () => {
    expect(parseExtensionUrl('not-a-url')).toBeNull();
  });

  it('handles URLs with query parameters', () => {
    const result = parseExtensionUrl('https://test.com/page?foo=bar');
    expect(result?.domain).toBe('test.com');
    expect(result?.path).toBe('/page');
  });
});

Mocking Chrome APIs for Tests

Chrome APIs present a unique testing challenge because they’re only available within the Chrome browser environment. Effective mocking strategies are essential for writing meaningful tests that validate your extension’s behavior without launching a browser.

Creating Comprehensive Chrome Mocks

A well-structured Chrome mock provides realistic behavior while allowing tests to verify interactions. The mock should implement the same async patterns as the real Chrome APIs, including proper handling of the chrome.runtime.lastError property.

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

interface StorageData {
  [key: string]: unknown;
}

const storage: StorageData = {};

export const createChromeMock = () => ({
  storage: {
    local: {
      get: vi.fn(async (keys: string | string[] | null): Promise<StorageData> => {
        if (!keys) return { ...storage };
        const keyArray = Array.isArray(keys) ? keys : [keys];
        const result: StorageData = {};
        for (const key of keyArray) {
          if (key in storage) {
            result[key] = storage[key];
          }
        }
        return result;
      }),
      set: vi.fn(async (items: StorageData): Promise<void> => {
        Object.assign(storage, items);
      }),
      remove: vi.fn(async (keys: string | string[]): Promise<void> => {
        const keyArray = Array.isArray(keys) ? keys : [keys];
        keyArray.forEach((key) => delete storage[key]);
      }),
      clear: vi.fn(async (): Promise<void> => {
        Object.keys(storage).forEach((key) => delete storage[key]);
      }),
    },
    sync: {
      get: vi.fn(async () => ({})),
      set: vi.fn(async () => ({})),
    },
    onChanged: {
      addListener: vi.fn(),
      removeListener: vi.fn(),
    },
  },
  runtime: {
    lastError: null,
    id: 'test-extension-id',
    getURL: vi.fn((path: string) => `chrome-extension://test-id/${path}`),
    sendMessage: vi.fn(),
    onMessage: {
      addListener: vi.fn(),
      removeListener: vi.fn(),
    },
  },
  tabs: {
    query: vi.fn(async () => [{ id: 1, url: 'https://example.com', active: true }]),
    sendMessage: vi.fn(),
    create: vi.fn(),
  },
  alarms: {
    create: vi.fn(),
    get: vi.fn(),
    clear: vi.fn(),
    onAlarm: {
      addListener: vi.fn(),
    },
  },
  contextMenus: {
    create: vi.fn(),
    update: vi.fn(),
    remove: vi.fn(),
  },
  notifications: {
    create: vi.fn(),
    clear: vi.fn(),
  },
});

// Set up global chrome before tests
beforeAll(() => {
  globalThis.chrome = createChromeMock() as unknown as typeof chrome;
});

afterEach(() => {
  vi.clearAllMocks();
});

Mocking Message Passing Between Contexts

Extensions rely heavily on message passing between content scripts, background scripts, and popup pages. Testing these interactions requires careful mocking of the message listeners and sendMessage functions.

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

type MessageHandler = (message: unknown, sender: unknown, sendResponse: (response: unknown) => void) => void;

const listeners: MessageHandler[] = [];

export const createMessagingMock = () => ({
  sendMessage: vi.fn((message: unknown) => {
    // Simulate synchronous response
    let response: unknown;
    listeners.forEach((listener) => {
      listener(message, {}, (r) => (response = r));
    });
    return Promise.resolve(response);
  }),
  onMessage: {
    addListener: vi.fn((fn: MessageHandler) => {
      listeners.push(fn);
    }),
    removeListener: vi.fn((fn: MessageHandler) => {
      const index = listeners.indexOf(fn);
      if (index > -1) listeners.splice(index, 1);
    }),
  },
});

export const sendMockMessage = async (message: unknown): Promise<unknown> => {
  let response: unknown;
  const sendResponse = (r: unknown) => {
    response = r;
  };

  listeners.forEach((listener) => {
    listener(message, {}, sendResponse);
  });

  return response;
};

Integration Testing with Puppeteer and Playwright

Integration tests verify that your extension components work together correctly, testing against the actual Chrome APIs but in a controlled browser environment. Puppeteer and Playwright both support loading unpacked extensions, enabling realistic testing scenarios.

Setting Up Puppeteer for Extension Testing

Puppeteer provides excellent support for testing Chrome extensions through the --load-extension flag. This allows you to load your built extension into a headless Chrome instance and interact with it programmatically.

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

describe('Extension Popup Integration Tests', () => {
  let browser: Browser;
  let popupPage: Page;
  const extensionPath = path.resolve(__dirname, '../../dist');

  beforeAll(async () => {
    browser = await puppeteer.launch({
      headless: 'new',
      args: [
        `--disable-extensions-except=${extensionPath}`,
        `--load-extension=${extensionPath}`,
        '--no-sandbox',
        '--disable-setuid-sandbox',
      ],
    });
  });

  afterAll(async () => {
    await browser.close();
  });

  it('loads the popup successfully', async () => {
    const targets = await browser.targets();
    const extensionTarget = targets.find(
      (target) => target.type() === 'service_worker'
    );
    
    expect(extensionTarget).toBeDefined();
    
    const extURL = extensionTarget?.url() || '';
    const extId = extURL.split('/')[2];
    
    popupPage = await browser.newPage();
    await popupPage.goto(`chrome-extension://${extId}/popup.html`);
    
    const title = await popupPage.title();
    expect(title).toBe('My Extension');
  });

  it('displays the enabled state correctly', async () => {
    await popupPage.waitForSelector('#status');
    const status = await popupPage.$eval('#status', (el) => el.textContent);
    expect(status).toContain('Enabled');
  });

  it('toggles state on button click', async () => {
    await popupPage.click('#toggle-button');
    await popupPage.waitForTimeout(500);
    
    const status = await popupPage.$eval('#status', (el) => el.textContent);
    expect(status).toContain('Disabled');
  });
});

Setting Up Playwright for Extension Testing

Playwright offers similar capabilities to Puppeteer but with a more modern API and better cross-browser support. The Playwright test runner provides powerful fixtures for managing extension lifecycles.

// e2e/playwright/extension.spec.ts
import { test, expect, chromium, Browser, Page } from '@playwright/test';
import path from 'path';

test.describe('Chrome Extension E2E', () => {
  let browser: Browser;
  let extensionId: string;

  test.beforeAll(async () => {
    browser = await chromium.launch({
      args: [
        `--disable-extensions-except=${path.resolve(__dirname, '../../dist')}`,
        `--load-extension=${path.resolve(__dirname, '../../dist')}`,
      ],
    });
    
    const targets = browser.targets();
    const serviceWorkerTarget = targets.find((t) => t.type() === 'service_worker');
    const url = serviceWorkerTarget?.url() || '';
    extensionId = url.split('/')[2];
  });

  test.afterAll(async () => {
    await browser.close();
  });

  test('popup renders and responds to interactions', async () => {
    const popup = await browser.newPage();
    await popup.goto(`chrome-extension://${extensionId}/popup.html`);
    
    // Verify popup content
    await expect(popup.locator('#app')).toBeVisible();
    
    // Test button interaction
    await popup.click('#action-button');
    await expect(popup.locator('#result')).toContainText('Action completed');
  });

  test('options page loads and saves settings', async () => {
    const options = await browser.newPage();
    await options.goto(`chrome-extension://${extensionId}/options.html`);
    
    // Fill and save settings
    await options.fill('#api-key', 'test-api-key');
    await options.click('#save-button');
    
    // Verify save confirmation
    await expect(options.locator('#save-status')).toContainText('Saved');
  });
});

E2E Testing Extension UI Components

End-to-end tests verify complete user workflows across your extension’s various interfaces. This includes testing the popup, options page, side panel, and interactions with content scripts on web pages.

Testing Popup Functionality

The popup is often the primary interface users interact with, making thorough testing essential. Test both the visual rendering and the functional behavior of all interactive elements.

// e2e/playwright/popup.spec.ts
test('complete popup workflow', async ({ browser }) => {
  // Load extension
  const extBrowser = await chromium.launch({
    args: [`--load-extension=${buildPath}`],
  });
  
  const popup = await extBrowser.newPage();
  await popup.goto(`chrome-extension://${extId}/popup.html`);
  
  // Initial state verification
  await expect(popup.locator('.user-name')).toContainText('Guest');
  
  // Test login flow
  await popup.fill('#username', 'testuser');
  await popup.fill('#password', 'password123');
  await popup.click('#login-button');
  
  // Verify logged in state
  await expect(popup.locator('.user-name')).toContainText('testuser');
  await expect(popup.locator('#login-form')).not.toBeVisible();
  
  // Test settings toggle
  await popup.click('#settings-toggle');
  await expect(popup.locator('.settings-panel')).toBeVisible();
  
  await extBrowser.close();
});

Testing Options Page

The options page often contains complex form interactions and should be tested thoroughly to ensure user settings are persisted correctly.

// e2e/playwright/options.spec.ts
test('options page settings persistence', async ({ browser }) => {
  const options = await browser.newPage();
  await options.goto(`chrome-extension://${extId}/options.html`);
  
  // Test form fields
  await options.selectOption('#theme', 'dark');
  await options.check('#enable-notifications');
  await options.fill('#api-endpoint', 'https://api.example.com');
  
  // Save settings
  await options.click('#save-button');
  
  // Verify success message
  await expect(options.locator('.success-message')).toBeVisible();
  
  // Reload page to verify persistence
  await options.reload();
  await expect(options.locator('#theme')).toHaveValue('dark');
  await expect(options.locator('#enable-notifications')).toBeChecked();
  await expect(options.locator('#api-endpoint')).toHaveValue('https://api.example.com');
});

Visual Regression Testing

Visual regression testing captures screenshots of your extension UI and compares them against baseline images to detect unintended visual changes. This is particularly valuable for catching CSS regressions that might not cause functional failures.

Setting Up Visual Regression Testing

// e2e/playwright/visual.spec.ts
import { test, expect } from '@playwright/test';
import { visualRegression } from '@playwright/test';

test('popup visual regression', async ({ page }) => {
  await page.goto(`chrome-extension://${extId}/popup.html`);
  
  // Wait for any animations to complete
  await page.waitForTimeout(1000);
  
  // Capture and compare
  await expect(page.locator('#popup-root')).toHaveScreenshot('popup-default.png', {
    maxDiffPixelRatio: 0.1,
  });
});

test('options page visual states', async ({ page }) => {
  await page.goto(`chrome-extension://${extId}/options.html`);
  
  // Test different theme states
  await page.evaluate(() => {
    document.documentElement.setAttribute('data-theme', 'dark');
  });
  
  await expect(page.locator('body')).toHaveScreenshot('options-dark.png');
  
  await page.evaluate(() => {
    document.documentElement.setAttribute('data-theme', 'light');
  });
  
  await expect(page.locator('body')).toHaveScreenshot('options-light.png');
});

Performance Testing

Performance testing ensures your extension doesn’t negatively impact browser performance or user experience. Key areas to test include startup time, memory usage, and the performance impact of content scripts.

Measuring Extension Performance

// e2e/playwright/performance.spec.ts
test('extension performance metrics', async ({ browser }) => {
  const page = await browser.newPage();
  
  // Measure page load performance
  await page.goto('https://example.com');
  
  // Inject content script manually for testing
  await page.evaluate(() => {
    // Simulate content script execution
    window.__EXTENSION_START = performance.now();
  });
  
  // Wait for content script to initialize
  await page.waitForFunction(() => (window as any).__EXTENSION_END);
  
  const metrics = await page.evaluate(() => {
    const start = (window as any).__EXTENSION_START;
    const end = (window as any).__EXTENSION_END;
    return {
      executionTime: end - start,
    };
  });
  
  // Assert reasonable performance
  expect(metrics.executionTime).toBeLessThan(100); // Should complete in <100ms
});

Test Coverage Strategies

Achieving comprehensive test coverage requires a strategic approach that balances code coverage metrics with meaningful test scenarios that verify actual user functionality.

Coverage Configuration

// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      include: [
        'src/**/*.ts',
        '!src/**/*.d.ts',
        '!src/**/*.test.ts',
      ],
      exclude: [
        'src/types/**',
        'src/generated/**',
      ],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 75,
        statements: 80,
      },
    },
  },
});

Coverage by Context

Different extension contexts require different testing approaches:

  1. Background Service Worker: Test message handlers, alarm callbacks, and API interactions through mocked Chrome APIs
  2. Content Scripts: Test DOM manipulation and page interaction in jsdom or through E2E tests
  3. Popup/Options Pages: Test UI components and user interactions with jsdom for unit tests, E2E for integration
  4. Shared Utilities: Pure functions that can be tested in isolation without Chrome APIs

CI/CD Pipeline Setup for Extensions

Continuous integration ensures your extension is tested on every commit and pull request. A well-configured pipeline catches regressions before they reach users.

GitHub Actions Workflow

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

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

jobs:
  unit-test:
    name: Unit Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run unit tests
        run: npm run test:unit
      
      - name: Run coverage
        run: npm run test:coverage

  build-test:
    name: Build and E2E Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Build extension
        run: npm run build
      
      - name: Install Playwright
        run: npx playwright install chromium
      
      - name: Run E2E tests
        run: npm run test:e2e

  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
      
      - name: Install and lint
        run: |
          npm ci
          npm run lint

Testing Across Chrome Versions

# .github/workflows/multi-browser.yml
jobs:
  test-chrome-versions:
    strategy:
      matrix:
        channel: [stable, beta, dev]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Install Playwright
        run: npx playwright install chromium --with-deps
      
      - name: Run tests on ${{ matrix.channel }}
        run: |
          npx playwright test \
            --browser=chromium \
            --browser-channel=${{ matrix.channel }}

Manual Testing Checklist

Automated tests can’t catch every issue. A comprehensive manual testing checklist ensures thorough validation before release:

Common Testing Pitfalls

Avoid these common mistakes when testing Chrome extensions:

  1. Not mocking chrome.runtime.lastError: Many Chrome API methods set this property, and failing to initialize it can cause tests to miss error handling bugs
  2. Forgetting async behavior: Chrome APIs are asynchronous; always use async/await in tests
  3. Testing implementation details: Focus on behavior, not implementation—tests that are too tightly coupled break easily
  4. Ignoring extension context: Content scripts run in an isolated world; globals from the page aren’t available
  5. Not cleaning up between tests: Each test should start with a clean state to avoid test interdependencies

Additional Resources


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

No previous article
No next article