Automated Testing for Chrome Extensions: Puppeteer, Playwright, and CI Integration

26 min read

Automated Testing for Chrome Extensions: Puppeteer, Playwright, and CI Integration

Testing Chrome extensions presents unique challenges that standard web application testing frameworks were not designed to handle. Your extension code runs across multiple isolated contexts — service workers, content scripts, popups, options pages, and side panels — each with its own lifecycle and execution environment. This guide covers the tools, patterns, and CI integration strategies that enable reliable automated testing for Chrome extensions.

Table of Contents


Understanding Extension Testing Contexts

Before diving into the tools, it is essential to understand the distinct contexts that comprise a Chrome extension:

Context Lifecycle Debug Access Testing Complexity
Service Worker Event-driven, can be terminated DevTools via chrome://extensions High - requires event triggering
Content Script Tied to page lifecycle Page DevTools Medium - runs in page context
Popup Ephemeral - closes on blur DevTools via inspect popup High - must stay open
Options Page Persistent Standard DevTools Low - like regular web page
Side Panel Persistent while browser open DevTools via inspect Medium

Each context requires different testing approaches. Puppeteer and Playwright each have distinct strengths for these scenarios.


Loading Unpacked Extensions in Puppeteer

Puppeteer provides direct support for loading Chrome extensions via the --load-extension flag. This allows you to launch a headless or headed Chrome browser with your unpacked extension automatically loaded.

Basic Setup

// puppeteer/setup.js
const puppeteer = require('puppeteer');

async function createExtensionBrowser() {
  const browser = await puppeteer.launch({
    headless: false, // Extensions require headed mode
    args: [
      '--disable-dev-shm-usage',
      '--no-sandbox',
      // Load your unpacked extension
      `--load-extension=${process.cwd()}/dist`,
    ],
    defaultViewport: null,
  });

  return browser;
}

async function runTests() {
  const browser = await createExtensionBrowser();
  const pages = await browser.pages();
  
  // The first page is the browser's initial page
  const targetPage = pages[0];
  
  // Navigate to a test page
  await targetPage.goto('https://example.com');
  
  // Your tests here...
  
  await browser.close();
}

runTests().catch(console.error);

Loading Multiple Extensions

If you need to test interactions between multiple extensions or use a helper extension:

const extensions = [
  process.cwd() + '/dist',        // Your extension
  process.cwd() + '/test-helpers' // Helper extension
];

const args = extensions.map(ext => `--load-extension=${ext}`);
const browser = await puppeteer.launch({
  headless: false,
  args: ['--disable-dev-shm-usage', ...args]
});

Accessing Extension Background

To interact with the service worker or background page:

async function getBackgroundPage(browser) {
  const targets = await browser.targets();
  const backgroundTarget = targets.find(
    target => target.type() === 'service_worker' && 
             target.url().includes('extension ID')
  );
  return backgroundTarget?.page();
}

const backgroundPage = await getBackgroundPage(browser);

Playwright Extension Fixtures

Playwright offers a more modern approach to extension testing through its experimental extension support. The chromium.launchPersistentContext method can load extensions in a persistent browser context.

Basic Playwright Extension Fixture

// playwright.config.js
const { defineConfig } = require('@playwright/test');

module.exports = defineConfig({
  testDir: './tests',
  timeout: 30000,
  use: {
    headless: false,
  },
  projects: [
    {
      name: 'extension',
      use: { 
        launchOptions: {
          args: [
            '--disable-dev-shm-usage',
            `--load-extension=${process.cwd()}/dist`,
          ],
        },
      },
    },
  ],
});

Using Playwright’s Experimental Extension API

Playwright 1.42+ introduced experimental support for testing with browser contexts that can load extensions:

// tests/extension.spec.js
const { test, expect } = require('@playwright/test');

test.describe('Chrome Extension Testing', () => {
  test('should load extension and verify popup', async ({ browser }) => {
    // Create a persistent context with extension loaded
    const context = await browser.newContext({
      args: [`--load-extension=${process.cwd()}/dist`]
    });
    
    const page = await context.newPage();
    await page.goto('https://example.com');
    
    // Access extension popup through chrome runtime
    // Note: Direct popup access is limited in Playwright
    
    await context.close();
  });
});

Extension Page Testing

Playwright excels at testing extension options pages and side panels:

test('options page loads correctly', async ({ page }) => {
  // Open extension options page directly
  await page.goto('chrome-extension://<EXTENSION_ID>/options.html');
  
  // Now you can test like a regular web page
  await expect(page.locator('#settings-form')).toBeVisible();
  await page.fill('#api-key', 'test-key-123');
  await page.click('#save-button');
  
  // Verify storage was updated
  const storage = await page.evaluate(() => {
    return new Promise(resolve => {
      chrome.storage.local.get(null, resolve);
    });
  });
  
  expect(storage['api-key']).toBe('test-key-123');
});

Testing Popup Interactions

Popup testing is challenging because popups close when they lose focus. Both Puppeteer and Playwright require specific strategies to keep popups open during testing.

Keeping Popup Open with Puppeteer

async function testPopup() {
  const browser = await puppeteer.launch({
    headless: false,
    args: [`--load-extension=${process.cwd()}/dist`]
  });
  
  const pages = await browser.pages();
  const extensionPage = pages[0];
  
  // Click the extension icon to open popup
  await extensionPage.click('#extension-icon-selector');
  
  // Wait for popup to be attached to DOM
  const popupTarget = await new Promise(resolve => {
    browser.once('targetcreated', async (target) => {
      if (target.type() === 'page' && target.url().startsWith('chrome-extension://')) {
        resolve(target);
      }
    });
  });
  
  const popupPage = await popupTarget.page();
  
  // Interact with popup - it stays open while DevTools is attached
  await popupPage.waitForSelector('#action-button');
  await popupPage.click('#action-button');
  
  const result = await popupPage.evaluate(() => {
    return document.querySelector('#result').textContent;
  });
  
  expect(result).toBe('Action completed');
  
  await browser.close();
}
test('popup interaction works', async ({ page }) => {
  // Navigate to any page
  await page.goto('https://example.com');
  
  // Simulate opening popup using chrome.action
  await page.evaluate(() => {
    chrome.action.openPopup();
  });
  
  // Wait for popup (requires popup to have a known URL pattern)
  // This is limited in Playwright - consider testing options page instead
});

For reliable popup testing, consider testing the underlying logic through the options page or by extracting popup functionality into testable modules.


Content Script Verification

Content scripts run in the context of web pages, making them testable alongside regular page interactions.

Testing Content Script Injection

async function testContentScript() {
  const browser = await puppeteer.launch({
    headless: false,
    args: [`--load-extension=${process.cwd()}/dist`]
  });
  
  const page = await browser.newPage();
  
  // Navigate to a page where your content script should inject
  await page.goto('https://example.com');
  
  // Wait for content script to inject
  await page.waitForFunction(() => {
    return window.__EXTENSION_INJECTED === true;
  }, { timeout: 5000 });
  
  // Verify content script modifications
  const hasExtensionUI = await page.evaluate(() => {
    return document.querySelector('.extension-widget') !== null;
  });
  
  expect(hasExtensionUI).toBe(true);
  
  // Test communication between content script and page
  await page.click('.extension-widget button');
  
  const message = await page.evaluate(() => {
    return window.lastExtensionMessage;
  });
  
  expect(message.action).toBe('user-clicked-button');
  
  await browser.close();
}

Verifying Content Script Message Passing

test('content script sends messages to background', async ({ page }) => {
  await page.goto('https://example.com');
  
  // Set up listener for messages from content script
  const messagePromise = page.waitForEvent('console', msg => 
    msg.type() === 'log' && msg.text().startsWith('[ContentScript]')
  );
  
  // Trigger content script action
  await page.click('.extension-trigger');
  
  const logMessage = await messagePromise;
  expect(logMessage.text()).toContain('sending message to background');
});

Service Worker Event Testing

Service workers are the most challenging context to test because they are event-driven and can be terminated at any time. Testing requires careful orchestration of events.

Testing Service Worker Lifecycle Events

async function testServiceWorkerEvents() {
  const browser = await puppeteer.launch({
    headless: false,
    args: [`--load-extension=${process.cwd()}/dist`]
  });
  
  // Get the background page
  const targets = await browser.targets();
  const swTarget = targets.find(t => 
    t.type() === 'service_worker' && 
    t.url().includes('background.js')
  );
  
  const swPage = await swTarget.page();
  
  // Enable console logging from service worker
  swPage.on('console', msg => console.log('[SW]', msg.text()));
  
  // Trigger install event by reloading extension
  await swTarget.page().evaluate(() => {
    chrome.runtime.reload();
  });
  
  // Wait for install handler to execute
  await new Promise(resolve => setTimeout(resolve, 1000));
  
  const installLogged = await swPage.evaluate(() => {
    return window.__installEventFired === true;
  });
  
  expect(installLogged).toBe(true);
  
  await browser.close();
}

Testing Alarms and Scheduled Tasks

test('alarm events fire correctly', async ({ browser }) => {
  const swTarget = await getServiceWorkerTarget(browser);
  const swPage = await swPage.target().page();
  
  // Create an alarm programmatically
  await swPage.evaluate(() => {
    chrome.alarms.create('test-alarm', { delayInMinutes: 0.01 });
  });
  
  // Wait for alarm event
  const alarmFired = await swPage.waitForFunction(() => {
    return window.__alarmFired === true;
  }, { timeout: 5000 });
  
  expect(alarmFired).toBe(true);
});

Screenshot Comparison for Visual Regression

Visual regression testing helps catch unintended UI changes in popups, options pages, and side panels.

Setting Up Screenshot Tests with Playwright

const { test, expect } = require('@playwright/test');
const path = require('path');

test('popup visual regression', async ({ page }) => {
  await page.goto('chrome-extension://<EXTENSION_ID>/popup.html');
  
  // Wait for all dynamic content to load
  await page.waitForSelector('.content-loaded');
  
  // Take screenshot
  await expect(page.locator('body')).toHaveScreenshot('popup-default.png', {
    maxDiffPixelRatio: 0.1,
  });
});

test('options page visual regression', async ({ page }) => {
  await page.goto('chrome-extension://<EXTENSION_ID>/options.html');
  await page.waitForSelector('#settings-loaded');
  
  await expect(page.locator('body')).toHaveScreenshot('options-page.png', {
    maxDiffPixelRatio: 0.1,
  });
});

Baseline Management

Store baseline screenshots in a dedicated directory and update them intentionally:

# Update baselines
npx playwright test --update-snapshots

# Run only visual tests
npx playwright test --grep "visual regression"

End-to-End Test Patterns

Comprehensive E2E tests verify the entire extension flow from user interaction through all extension contexts.

Complete E2E Flow Test

test('complete user flow', async ({ browser }) => {
  const context = await browser.newContext();
  const page = await context.newPage();
  
  // 1. User visits a webpage
  await page.goto('https://example.com/products');
  
  // 2. Content script injects and displays UI
  await page.waitForSelector('.extension-price-tracker');
  
  // 3. User adds item to tracking
  await page.click('.extension-price-tracker button.track');
  
  // 4. Verify message sent to background
  const messageSent = await page.evaluate(() => {
    return window.__messageToBackground?.action === 'track-product';
  });
  expect(messageSent).toBe(true);
  
  // 5. Check storage was updated
  const trackedItems = await page.evaluate(() => {
    return new Promise(resolve => {
      chrome.storage.local.get('trackedItems', resolve);
    });
  });
  expect(trackedItems.trackedItems).toHaveLength(1);
  
  // 6. Open options page and verify sync
  await page.goto('chrome-extension://<EXTENSION_ID>/options.html');
  await page.waitForSelector('#tracked-list li');
  
  const listItems = await page.locator('#tracked-list li').count();
  expect(listItems).toBe(1);
  
  await context.close();
});

Multi-Tab Testing

test('extension works across multiple tabs', async ({ browser }) => {
  const context = await browser.newContext();
  
  const tab1 = await context.newPage();
  const tab2 = await context.newPage();
  
  // Both tabs load the same site
  await Promise.all([
    tab1.goto('https://example.com/page1'),
    tab2.goto('https://example.com/page2')
  ]);
  
  // Extension injects into both
  await Promise.all([
    tab1.waitForSelector('.extension-widget'),
    tab2.waitForSelector('.extension-widget')
  ]);
  
  // Actions in one tab reflect in the other through background
  await tab1.click('.extension-widget button');
  
  await tab2.waitForFunction(() => {
    return window.__syncStatus === 'updated';
  });
  
  await context.close();
});

Mocking Chrome APIs

Testing extension logic often requires mocking Chrome APIs to control their behavior or verify they are called correctly.

Using Puppeteer to Mock APIs

async function mockChromeAPI(browser) {
  const page = await browser.newPage();
  
  // Mock chrome.storage.local
  await page.evaluateOnNewDocument(() => {
    const storage = {};
    
    chrome.storage.local.get = (keys, callback) => {
      const result = {};
      if (typeof keys === 'string') {
        result[keys] = storage[keys];
      } else if (Array.isArray(keys)) {
        keys.forEach(key => {
          result[key] = storage[key];
        });
      } else if (keys === null) {
        return Object.assign({}, storage);
      }
      callback(result);
    };
    
    chrome.storage.local.set = (items, callback) => {
      Object.assign(storage, items);
      callback?.();
    };
    
    // Mock other APIs as needed
    chrome.runtime.sendMessage = (message, responseCallback) => {
      console.log('Mock: sendMessage', message);
      if (responseCallback) {
        responseCallback({ success: true });
      }
    };
  });
  
  return page;
}

Using Playwright’s Route Matching

test('extension API calls intercepted', async ({ page }) => {
  // Intercept extension API calls
  await page.route('**/chrome-extension://*/*', async (route) => {
    const request = route.request();
    
    // Log or modify requests
    console.log('Extension request:', request.url());
    
    await route.continue();
  });
  
  await page.goto('chrome-extension://<EXTENSION_ID>/options.html');
  // Continue with tests...
});

GitHub Actions CI Setup

Running extension tests in CI requires special configuration since headless Chrome has limitations with extensions.

Basic CI Workflow

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

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

jobs:
  test:
    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 browsers
        run: npx playwright install chromium
      
      - name: Run tests
        run: npx playwright test --project=extension
        
      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: playwright-report/

Handling Extension Testing in CI

Extensions require a modified approach in CI environments:

# Use Xvfb for headed testing
- name: Run tests with Xvfb
  run: xvfb-run --auto-servernum npx playwright test

# Or use the newer headless mode with extensions
- name: Run extension tests
  run: |
    npx playwright test \
      --project=extension \
      --headed=false \
      --browser=chromium

CI-Specific Test Configuration

// playwright.config.js
module.exports = defineConfig({
  testDir: './tests',
  timeout: 30000,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  
  use: {
    baseURL: 'https://example.com',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  
  projects: [
    {
      name: 'extension',
      use: {
        launchOptions: {
          args: process.env.CI ? [
            '--no-sandbox',
            '--disable-dev-shm-usage',
            '--disable-gpu',
            `--load-extension=${process.cwd()}/dist`,
          ] : [
            `--load-extension=${process.cwd()}/dist`,
          ],
        },
      },
    },
  ],
});

Test Coverage Reporting

Measuring test coverage helps identify untested code paths in your extension.

Setting Up Coverage with Istanbul

// Add to your build process
// 1. Instrument your code
// 2. Run tests
// 3. Collect coverage

// jest.config.js or similar
module.exports = {
  collectCoverageFrom: [
    'src/**/*.js',
    '!src/**/*.test.js',
  ],
  coverageReporters: ['html', 'lcov', 'text-summary'],
};

Coverage for Content Scripts

Content script coverage requires injecting instrumented code:

// For content scripts, use chrome.scripting.executeScript with instrumented code
async function getContentScriptCoverage() {
  return page.evaluate(() => {
    // Access coverage data from __coverage__ object
    // injected by instrumented content script
    return window.__coverage__;
  });
}

Reporting with GitHub Actions

- name: Generate coverage report
  run: npm run test:coverage

- name: Coveralls
  if: github.event_name == 'pull_request'
  uses: coverallsapp/github-action@v2
  with:
    path-to-slcov: coverage/lcov.info

Fixture Management

Creating reusable test fixtures improves test maintainability and reduces duplication.

Puppeteer Fixture Factory

// fixtures/extension.js
class ExtensionFixture {
  constructor(browser, extensionPath) {
    this.browser = browser;
    this.extensionPath = extensionPath;
    this.extensionId = null;
  }
  
  async initialize() {
    // Discover extension ID after loading
    const targets = await this.browser.targets();
    const extTarget = targets.find(t => 
      t.type() === 'service_worker' && 
      t.url().includes(this.extensionPath)
    );
    
    const url = extTarget.url();
    this.extensionId = url.match(/chrome-extension:\/\/([a-z]+)/)[1];
    
    return this;
  }
  
  getBackgroundPage() {
    return this.browser.targets().find(t => 
      t.type() === 'service_worker' && 
      t.url().includes(this.extensionId)
    ).page();
  }
  
  getOptionsPage() {
    return this.browser.newPage().then(page => 
      page.goto(`chrome-extension://${this.extensionId}/options.html`)
        .then(() => page)
    );
  }
}

async function createExtensionFixture(browser, path) {
  const fixture = new ExtensionFixture(browser, path);
  return fixture.initialize();
}

module.exports = { createExtensionFixture };

Playwright Test Fixtures

// fixtures/playwright.js
const { test as base } = require('@playwright/test');

const extensionTest = base.extend({
  extensionId: async ({ browser }, use) => {
    const context = await browser.newContext({
      args: [`--load-extension=${process.cwd()}/dist`]
    });
    
    // Extract extension ID
    const page = await context.newPage();
    await page.goto('chrome-extension://<temp>/manifest.json');
    
    // Get actual ID from background
    const extId = await context.waitForEvent('webrequest');
    
    await use(extId);
    await context.close();
  },
  
  backgroundPage: async ({ browser, extensionId }, use) => {
    const targets = await browser.targets();
    const swTarget = targets.find(t => 
      t.type() === 'service_worker' && 
      t.url().includes(extensionId)
    );
    
    const page = await swTarget.page();
    await use(page);
  },
});

module.exports = { extensionTest };

Summary

Automated testing for Chrome extensions requires understanding the unique challenges of multi-context execution environments. Key takeaways from this guide:

Combine these tools and patterns with the CI/CD pipeline setup and development workflows to build a robust testing infrastructure for your Chrome extension.



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

No previous article
No next article