Unit Testing Chrome Extensions with Jest and Vitest: Complete Guide (2026)

AppBooster Team · · 5 min read
Unit testing code with green checkmarks

Why Unit Test Chrome Extensions?

Unit tests verify that individual functions and modules work correctly in isolation — without launching a browser. They’re fast (milliseconds per test), reliable (no flaky browser automation), and catch logic bugs early.

The challenge with extension code is the chrome.* API dependency. You can’t call chrome.storage.local.get() in Node.js. The solution: mock the Chrome APIs and use dependency injection.


Setup: Jest

Install

npm install jest --save-dev

jest.config.js

module.exports = {
  testEnvironment: 'node',
  setupFiles: ['./tests/setup-chrome-mocks.js'],
  testMatch: ['**/tests/**/*.test.js']
};

tests/setup-chrome-mocks.js

global.chrome = {
  storage: {
    local: {
      get: jest.fn(),
      set: jest.fn(),
      remove: jest.fn()
    },
    sync: {
      get: jest.fn(),
      set: jest.fn()
    },
    session: {
      get: jest.fn(),
      set: jest.fn()
    }
  },
  tabs: {
    query: jest.fn(),
    create: jest.fn(),
    update: jest.fn(),
    remove: jest.fn()
  },
  runtime: {
    getManifest: jest.fn(() => ({ version: '1.0.0' })),
    sendMessage: jest.fn(),
    getURL: jest.fn((path) => `chrome-extension://test-id/${path}`),
    lastError: null
  },
  action: {
    setBadgeText: jest.fn(),
    setBadgeBackgroundColor: jest.fn()
  },
  notifications: {
    create: jest.fn(),
    clear: jest.fn()
  },
  alarms: {
    create: jest.fn(),
    get: jest.fn(),
    clear: jest.fn()
  }
};

Setup: Vitest

Install

npm install vitest --save-dev

vitest.config.js

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    setupFiles: ['./tests/setup-chrome-mocks.js'],
    globals: true
  }
});

The mock file is the same — just replace jest.fn() with vi.fn().


Testing Non-API Code

Functions that don’t use Chrome APIs can be tested directly:

// src/utils.js
function formatTabCount(count) {
  if (count === 0) return 'No tabs';
  if (count === 1) return '1 tab';
  if (count > 99) return '99+';
  return `${count} tabs`;
}

function extractDomain(url) {
  try {
    return new URL(url).hostname.replace('www.', '');
  } catch {
    return null;
  }
}

module.exports = { formatTabCount, extractDomain };
// tests/utils.test.js
const { formatTabCount, extractDomain } = require('../src/utils');

describe('formatTabCount', () => {
  test('returns "No tabs" for 0', () => {
    expect(formatTabCount(0)).toBe('No tabs');
  });

  test('returns singular for 1', () => {
    expect(formatTabCount(1)).toBe('1 tab');
  });

  test('returns "99+" for counts over 99', () => {
    expect(formatTabCount(150)).toBe('99+');
  });

  test('returns count with "tabs" for normal values', () => {
    expect(formatTabCount(42)).toBe('42 tabs');
  });
});

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

  test('returns null for invalid URL', () => {
    expect(extractDomain('not-a-url')).toBeNull();
  });
});

Testing Chrome API Code

Mocking Storage

// src/settings.js
async function getSettings() {
  const result = await chrome.storage.sync.get('settings');
  return result.settings || { theme: 'light', notifications: true };
}

async function saveSetting(key, value) {
  const settings = await getSettings();
  settings[key] = value;
  await chrome.storage.sync.set({ settings });
  return settings;
}

module.exports = { getSettings, saveSetting };
// tests/settings.test.js
const { getSettings, saveSetting } = require('../src/settings');

beforeEach(() => {
  jest.clearAllMocks();
});

describe('getSettings', () => {
  test('returns stored settings', async () => {
    chrome.storage.sync.get.mockResolvedValue({
      settings: { theme: 'dark', notifications: false }
    });

    const settings = await getSettings();
    expect(settings.theme).toBe('dark');
    expect(settings.notifications).toBe(false);
  });

  test('returns defaults when no settings stored', async () => {
    chrome.storage.sync.get.mockResolvedValue({});

    const settings = await getSettings();
    expect(settings.theme).toBe('light');
    expect(settings.notifications).toBe(true);
  });
});

describe('saveSetting', () => {
  test('updates a single setting', async () => {
    chrome.storage.sync.get.mockResolvedValue({
      settings: { theme: 'light', notifications: true }
    });
    chrome.storage.sync.set.mockResolvedValue(undefined);

    const result = await saveSetting('theme', 'dark');

    expect(result.theme).toBe('dark');
    expect(result.notifications).toBe(true);
    expect(chrome.storage.sync.set).toHaveBeenCalledWith({
      settings: { theme: 'dark', notifications: true }
    });
  });
});

Testing Tab Operations

// src/tab-manager.js
async function getActiveTabId() {
  const [tab] = await chrome.tabs.query({
    active: true,
    currentWindow: true
  });
  return tab?.id ?? null;
}

async function closeDuplicateTabs() {
  const tabs = await chrome.tabs.query({});
  const seen = new Map();
  const duplicates = [];

  for (const tab of tabs) {
    if (seen.has(tab.url)) {
      duplicates.push(tab.id);
    } else {
      seen.set(tab.url, tab.id);
    }
  }

  if (duplicates.length > 0) {
    await chrome.tabs.remove(duplicates);
  }

  return duplicates.length;
}

module.exports = { getActiveTabId, closeDuplicateTabs };
// tests/tab-manager.test.js
const { getActiveTabId, closeDuplicateTabs } = require('../src/tab-manager');

describe('getActiveTabId', () => {
  test('returns active tab ID', async () => {
    chrome.tabs.query.mockResolvedValue([{ id: 42, active: true }]);

    const id = await getActiveTabId();
    expect(id).toBe(42);
  });

  test('returns null when no active tab', async () => {
    chrome.tabs.query.mockResolvedValue([]);

    const id = await getActiveTabId();
    expect(id).toBeNull();
  });
});

describe('closeDuplicateTabs', () => {
  test('closes duplicate tabs', async () => {
    chrome.tabs.query.mockResolvedValue([
      { id: 1, url: 'https://example.com' },
      { id: 2, url: 'https://other.com' },
      { id: 3, url: 'https://example.com' }, // duplicate
      { id: 4, url: 'https://example.com' }  // duplicate
    ]);
    chrome.tabs.remove.mockResolvedValue(undefined);

    const count = await closeDuplicateTabs();

    expect(count).toBe(2);
    expect(chrome.tabs.remove).toHaveBeenCalledWith([3, 4]);
  });

  test('does nothing with no duplicates', async () => {
    chrome.tabs.query.mockResolvedValue([
      { id: 1, url: 'https://example.com' },
      { id: 2, url: 'https://other.com' }
    ]);

    const count = await closeDuplicateTabs();

    expect(count).toBe(0);
    expect(chrome.tabs.remove).not.toHaveBeenCalled();
  });
});

Dependency Injection Pattern

For better testability, inject Chrome APIs rather than using the global:

// src/analytics.js
function createAnalytics(storage = chrome.storage.local) {
  return {
    async trackEvent(name, data) {
      const { events = [] } = await storage.get('events');
      events.push({ name, data, timestamp: Date.now() });
      await storage.set({ events });
    },

    async getEvents() {
      const { events = [] } = await storage.get('events');
      return events;
    }
  };
}
// tests/analytics.test.js
test('tracks events', async () => {
  const mockStorage = {
    get: jest.fn().mockResolvedValue({ events: [] }),
    set: jest.fn().mockResolvedValue(undefined)
  };

  const analytics = createAnalytics(mockStorage);
  await analytics.trackEvent('click', { button: 'save' });

  expect(mockStorage.set).toHaveBeenCalledWith({
    events: [expect.objectContaining({
      name: 'click',
      data: { button: 'save' }
    })]
  });
});

Test Organization

tests/
├── setup-chrome-mocks.js   # Global Chrome API mocks
├── utils.test.js            # Pure utility functions
├── settings.test.js         # Storage-based settings
├── tab-manager.test.js      # Tab operations
├── analytics.test.js        # Analytics module
└── message-handler.test.js  # Message passing logic

What’s Next

Unit tests are the foundation of a reliable extension. Mock Chrome APIs, use dependency injection for testability, and complement unit tests with E2E tests for full coverage.

Build and optimize your extension with ExtensionBooster.

Share this article

Build better extensions with free tools

Icon generator, MV3 converter, review exporter, and more — no signup needed.

Related Articles