Testing Chrome Extensions with Jest and Puppeteer: A Complete Guide

22 min read

Testing Chrome Extensions with Jest and Puppeteer: A Complete Guide

Testing Chrome Extensions with Jest and Puppeteer: A Complete Guide

Testing Chrome extensions presents unique challenges that differ significantly from traditional web application testing. Unlike standard web apps, Chrome extensions operate across multiple contexts—background service workers, popup pages, content scripts, and options pages. Each of these components runs in its own isolated environment, making comprehensive testing essential for delivering reliable extensions.

In this comprehensive guide, we will explore how to leverage Jest and Puppeteer to create a robust testing strategy for your Chrome extensions. Jest provides an excellent framework for unit and integration tests, while Puppeteer enables powerful end-to-end testing that simulates real user interactions with your extension.


Why Testing Chrome Extensions Matters

Chrome extensions have become integral to enhancing browser functionality for millions of users. When an extension fails, it can disrupt workflows, lose user trust, and result in negative reviews. Proper testing ensures that your extension functions correctly across different scenarios, browsers, and user interactions.

The complexity of Chrome extensions stems from their multi-context architecture. A single extension might include background scripts that handle API calls, content scripts that manipulate web page DOM, popup interfaces for user interaction, and options pages for configuration. Each of these components needs thorough testing, and they also need to work correctly together.

Automated testing with Jest and Puppeteer allows you to catch bugs early, verify new features work as expected, and prevent regressions when updating existing functionality. This approach is especially valuable when working with the Chrome Extensions platform, where changes in browser behavior or the WebExtensions API can sometimes introduce unexpected issues.


Setting Up Your Testing Environment

Before diving into tests, you need to configure your development environment properly. This section covers the essential setup steps for testing Chrome extensions with Jest and Puppeteer.

Installing Required Dependencies

Start by installing the necessary packages in your extension project. You will need Jest for running tests, Puppeteer for browser automation, and several supporting libraries.

npm install --save-dev jest puppeteer jest-puppeteer

For TypeScript projects, you will also need the appropriate type definitions:

npm install --save-dev @types/jest @types/puppeteer ts-jest

Configuring Jest for Chrome Extension Testing

Create a Jest configuration file (jest.config.js) in your project root. This configuration needs to handle the unique aspects of Chrome extension testing, including special handling for extension-specific APIs and build outputs.

module.exports = {
  testEnvironment: 'node',
  preset: 'jest-puppeteer',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  testMatch: ['**/*.test.js'],
  collectCoverageFrom: [
    'src/**/*.js',
    '!src/**/*.test.js'
  ],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1'
  }
};

The jest-puppeteer preset simplifies the configuration by automatically setting up Puppeteer launch options and providing convenient globals for writing browser tests.

Setting Up Puppeteer for Extension Testing

Create a jest.setup.js file to configure Puppeteer specifically for Chrome extension testing. This setup will launch Chrome with the appropriate flags to load your extension.

const path = require('path');

beforeAll(async () => {
  const extensionPath = path.resolve(__dirname, 'dist');
  
  await jestPuppeteer.launch({
    headless: false,
    args: [
      `--disable-extensions-except=${extensionPath}`,
      `--load-extension=${extensionPath}`,
      '--no-sandbox',
      '--disable-setuid-sandbox'
    ]
  });
});

afterAll(async () => {
  await jestPuppeteer.resetBrowser();
});

This configuration ensures that Puppeteer launches Chrome with your extension loaded, allowing you to test it as a user would experience it.


Writing Unit Tests with Jest

Unit tests form the foundation of your testing strategy. They verify that individual functions and modules work correctly in isolation. For Chrome extensions, unit tests are particularly valuable for testing utility functions, data transformation logic, and business rules.

Testing Background Script Logic

Background service workers handle events and manage extension state. While you cannot directly test the service worker in isolation (it runs in a special Chrome context), you can test the logic that the background script uses.

Consider a background script that manages extension state:

// src/background/state.js
export class ExtensionState {
  constructor() {
    this.settings = {};
    this.cache = new Map();
  }

  setSetting(key, value) {
    this.settings[key] = value;
    this.persistSettings();
  }

  getSetting(key) {
    return this.settings[key];
  }

  setCache(key, value) {
    this.cache.set(key, {
      value,
      timestamp: Date.now()
    });
  }

  getCache(key) {
    const entry = this.cache.get(key);
    if (!entry) return null;
    
    const age = Date.now() - entry.timestamp;
    if (age > 3600000) { // 1 hour cache
      this.cache.delete(key);
      return null;
    }
    
    return entry.value;
  }

  persistSettings() {
    // Implementation would use chrome.storage
  }
}

You can test this logic without Chrome APIs:

// src/background/state.test.js
import { ExtensionState } from './state';

describe('ExtensionState', () => {
  let state;

  beforeEach(() => {
    state = new ExtensionState();
  });

  describe('setSetting', () => {
    it('should store a setting value', () => {
      state.setSetting('theme', 'dark');
      expect(state.getSetting('theme')).toBe('dark');
    });

    it('should overwrite existing settings', () => {
      state.setSetting('theme', 'light');
      state.setSetting('theme', 'dark');
      expect(state.getSetting('theme')).toBe('dark');
    });
  });

  describe('caching', () => {
    it('should store cached values', () => {
      state.setCache('user', { name: 'Test User' });
      expect(state.getCache('user')).toEqual({ name: 'Test User' });
    });

    it('should expire old cache entries', () => {
      // Manually manipulate timestamp to simulate expiration
      state.setCache('user', { name: 'Test User' });
      const entry = state.cache.get('user');
      entry.timestamp = Date.now() - 7200000; // 2 hours ago
      
      expect(state.getCache('user')).toBeNull();
    });
  });
});

Mocking Chrome APIs in Tests

Chrome extension APIs like chrome.storage, chrome.runtime, and chrome.tabs are not available in Node.js. You need to mock these APIs when testing components that depend on them.

// src/__mocks__/chrome.js
const chrome = {
  storage: {
    local: {
      get: jest.fn((keys, callback) => {
        callback({});
      }),
      set: jest.fn((items, callback) => {
        if (callback) callback();
      }),
      remove: jest.fn((keys, callback) => {
        if (callback) callback();
      })
    },
    sync: {
      get: jest.fn(),
      set: jest.fn(),
      remove: jest.fn()
    }
  },
  runtime: {
    lastError: null,
    sendMessage: jest.fn((message, callback) => {
      if (callback) callback({});
    }),
    onMessage: {
      addListener: jest.fn()
    }
  },
  tabs: {
    query: jest.fn((options, callback) => {
      callback([]);
    }),
    create: jest.fn((options, callback) => {
      if (callback) callback({ id: 123 });
    })
  }
};

module.exports = chrome;

Create a setup file that mocks Chrome before tests run:

// jest.setup.js
jest.mock('chrome', () => require('./src/__mocks__/chrome'));

Integration Testing with Jest and Puppeteer

Integration tests verify that different components of your extension work correctly together. Puppeteer enables you to load your extension in a real Chrome browser and interact with it programmatically.

Testing Popup Functionality

The popup is often the primary interface users interact with. Testing it ensures that user interactions produce expected results.

// tests/popup.test.js
describe('Chrome Extension Popup Tests', () => {
  beforeAll(async () => {
    // Get the extension ID from the loaded extension
    const targets = await browser.targets();
    const extensionTarget = targets.find(
      target => target.type() === 'service_worker' && 
                target.url().includes('manifest.json')
    );
    
    if (extensionTarget) {
      const extensionId = new URL(extensionTarget.url()).hostname;
      await page.goto(`chrome-extension://${extensionId}/popup.html`);
    }
  });

  describe('Popup UI', () => {
    it('should load the popup HTML', async () => {
      const title = await page.title();
      expect(title).toBeTruthy();
    });

    it('should display the main container', async () => {
      const container = await page.$('.popup-container');
      expect(container).toBeTruthy();
    });

    it('should have working buttons', async () => {
      const button = await page.$('#action-button');
      expect(button).toBeTruthy();
      
      const isVisible = await button.isVisible();
      expect(isVisible).toBe(true);
    });
  });

  describe('Popup Interactions', () => {
    it('should respond to button clicks', async () => {
      await page.click('#action-button');
      
      // Wait for expected behavior
      await page.waitForTimeout(500);
      
      // Verify the action was performed
      const statusElement = await page.$('#status');
      const statusText = await statusElement.textContent();
      expect(statusText).toContain('Action completed');
    });

    it('should update settings when changed', async () => {
      const checkbox = await page.$('#enable-feature');
      await checkbox.click();
      
      await page.waitForTimeout(300);
      
      // Verify storage was updated
      const storage = await page.evaluate(() => {
        return new Promise((resolve) => {
          chrome.storage.local.get('enabled', resolve);
        });
      });
      
      expect(storage.enabled).toBe(true);
    });
  });
});

Testing Content Script Injection

Content scripts run in the context of web pages and often need to interact with the page DOM. Testing these scripts ensures they work correctly on target websites.

// tests/content-script.test.js
describe('Content Script Tests', () => {
  const testUrl = 'https://example.com';

  beforeAll(async () => {
    await page.goto(testUrl);
  });

  describe('DOM Manipulation', () => {
    it('should inject the content script', async () => {
      // Check if content script has added its elements
      const injectedElement = await page.$('.extension-injected');
      expect(injectedElement).toBeTruthy();
    });

    it('should modify page elements correctly', async () => {
      const element = await page.$('.target-element');
      const classList = await element.getProperty('className');
      const classes = classList.jsonValue();
      
      expect(classes).toContain('extension-modified');
    });
  });

  describe('Message Passing', () => {
    it('should send messages to background script', async () => {
      // Trigger content script action
      await page.click('.trigger-action');
      
      // Wait for message to be sent
      await page.waitForTimeout(500);
      
      // Verify message was sent (would need custom evaluation)
      const messageSent = await page.evaluate(() => {
        return window.__messageSent === true;
      });
      
      expect(messageSent).toBe(true);
    });

    it('should receive responses from background', async () => {
      await page.click('.request-data');
      
      const data = await page.evaluate(() => {
        return window.__receivedData;
      });
      
      expect(data).toBeTruthy();
      expect(data.status).toBe('success');
    });
  });
});

End-to-End Testing with Puppeteer

End-to-end tests simulate real user workflows, verifying that your entire extension functions correctly from the user’s perspective.

Testing Complete User Flows

// tests/e2e/user-flows.test.js
describe('End-to-End User Flows', () => {
  beforeAll(async () => {
    // Navigate to a test page
    await page.goto('https://example-test-site.com');
  });

  describe('Complete Bookmark Workflow', () => {
    it('should allow saving a page as bookmark', async () => {
      // 1. User browses to a page
      await page.goto('https://example-test-site.com/article-1');
      
      // 2. User clicks extension icon to open popup
      await page.click('#extension-toolbar-icon');
      await page.waitForTimeout(500);
      
      // 3. Extension popup opens
      const popup = await page.$('.extension-popup');
      expect(popup).toBeTruthy();
      
      // 4. User clicks bookmark button
      await page.click('#bookmark-btn');
      await page.waitForTimeout(300);
      
      // 5. Verify success message appears
      const message = await page.$eval('.success-message', el => el.textContent);
      expect(message).toContain('Bookmarked');
      
      // 6. Close popup and verify bookmark persists
      await page.keyboard.press('Escape');
      await page.waitForTimeout(200);
      
      // 7. Open popup again to verify bookmark is listed
      await page.click('#extension-toolbar-icon');
      await page.waitForTimeout(500);
      
      const bookmarkExists = await page.evaluate(() => {
        const bookmarks = document.querySelectorAll('.bookmark-item');
        return bookmarks.length > 0;
      });
      
      expect(bookmarkExists).toBe(true);
    });

    it('should allow accessing saved bookmarks', async () => {
      // Open popup
      await page.click('#extension-toolbar-icon');
      await page.waitForTimeout(500);
      
      // Click on a saved bookmark
      await page.click('.bookmark-item:first-child');
      await page.waitForTimeout(500);
      
      // Verify navigation occurred
      const currentUrl = page.url();
      expect(currentUrl).toContain('article-1');
    });
  });
});

Testing Background Worker Behavior

Background service workers handle events even when the popup is not open. Testing these requires special techniques.

// tests/background/background-worker.test.js
describe('Background Service Worker Tests', () => {
  let backgroundPage;

  beforeAll(async () => {
    // Get the background page target
    const targets = await browser.targets();
    backgroundTarget = targets.find(
      target => target.type() === 'service_worker' &&
                target.url().includes('background.js')
    );
    
    backgroundPage = await backgroundTarget.page();
  });

  it('should initialize correctly', async () => {
    const isReady = await backgroundPage.evaluate(() => {
      return window.__extensionReady === true;
    });
    expect(isReady).toBe(true);
  });

  it('should handle alarms', async () => {
    // Set up listener for alarm
    await backgroundPage.evaluate(() => {
      window.__alarmFired = false;
      chrome.alarms.onAlarm.addListener((alarm) => {
        if (alarm.name === 'periodic-sync') {
          window.__alarmFired = true;
        }
      });
    });
    
    // Create an alarm (would typically be done by extension)
    await page.evaluate(() => {
      chrome.alarms.create('periodic-sync', { periodInMinutes: 0.1 });
    });
    
    // Wait for alarm to fire
    await page.waitForTimeout(2000);
    
    const alarmFired = await backgroundPage.evaluate(() => {
      return window.__alarmFired;
    });
    expect(alarmFired).toBe(true);
  });
});

Best Practices for Chrome Extension Testing

Implementing effective testing requires following established best practices that maximize test coverage while maintaining test maintainability.

Organize Tests Logically

Structure your tests to mirror your extension’s architecture. Keep unit tests, integration tests, and end-to-end tests in separate directories. This organization makes it easier to run specific test suites and understand test failures.

tests/
  unit/
    background/
    popup/
    content-scripts/
  integration/
    popup.test.js
    content-script.test.js
  e2e/
    user-flows.test.js

Use Appropriate Test Granularity

Not every test needs to be an end-to-end test. Use unit tests for logic validation, integration tests for component interaction, and end-to-end tests only for critical user flows. This approach balances test execution speed with coverage depth.

Handle Asynchronous Operations Properly

Chrome extension APIs are asynchronous. Always use proper async/await patterns and wait for operations to complete before making assertions.

// Good: Wait for async operation
await page.waitForSelector('.element');
const text = await page.$eval('.element', el => el.textContent);

// Bad: No waiting
const text = await page.$eval('.element', el => el.textContent);

Test Error Conditions

Ensure your extension handles errors gracefully. Test scenarios like network failures, invalid user input, and API errors.

it('should handle network errors gracefully', async () => {
  // Simulate network failure
  await page.setRequestInterception(true);
  page.on('request', (request) => {
    if (request.url().includes('api')) {
      request.abort('failed');
    } else {
      request.continue();
    }
  });
  
  // Trigger API call
  await page.click('#fetch-data-btn');
  
  // Verify error handling
  const errorMessage = await page.$eval('.error-message', el => el.textContent);
  expect(errorMessage).toContain('Network error');
});

Automating Tests in CI/CD Pipelines

Continuous integration ensures that tests run automatically on every code change, catching regressions before they reach production.

GitHub Actions Configuration

name: Chrome Extension Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          
      - name: Install dependencies
        run: npm ci
        
      - 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
        
      - name: Run E2E tests
        run: npm run test:e2e

Optimizing Test Execution

Running all tests on every commit can be slow. Consider parallelizing test execution and using test filtering:

# Run unit tests only (fast)
npm run test:unit

# Run specific test files
npm test -- --testPathPattern=popup

# Run tests in parallel
npm test -- --maxWorkers=4

Conclusion

Testing Chrome extensions with Jest and Puppeteer provides a comprehensive solution for ensuring extension quality and reliability. Jest excels at unit testing, allowing you to verify individual components and logic in isolation. Puppeteer enables powerful integration and end-to-end testing that simulates real user interactions.

The multi-context nature of Chrome extensions makes thorough testing particularly important. By implementing the strategies outlined in this guide—unit tests for logic, integration tests for component interaction, and end-to-end tests for critical user flows—you can build confidence in your extension’s correctness and maintainability.

Remember that test coverage is not the only metric that matters. Focus on testing critical paths, edge cases, and error conditions. A well-tested extension provides a better user experience and reduces the time spent debugging issues in production.

Start implementing these testing practices in your Chrome extension projects today, and you’ll see the benefits in code quality, developer confidence, and user satisfaction.

No previous article
No next article