Automated Testing for Chrome Extensions: Puppeteer, Playwright, and CI Integration
26 min readAutomated 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
- Loading Unpacked Extensions in Puppeteer
- Playwright Extension Fixtures
- Testing Popup Interactions
- Content Script Verification
- Service Worker Event Testing
- Screenshot Comparison for Visual Regression
- End-to-End Test Patterns
- Mocking Chrome APIs
- GitHub Actions CI Setup
- Test Coverage Reporting
- Fixture Management
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();
}
Popup Testing with Playwright
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:
- Puppeteer provides direct control over extension loading with
--load-extensionand is well-suited for low-level browser automation. - Playwright offers a more modern API with better TypeScript support and experimental extension fixtures, making it ideal for teams adopting newer testing patterns.
- Popup testing requires keeping the popup open through DevTools attachment or testing related functionality through options pages.
- Content scripts can be tested alongside regular page interactions, enabling comprehensive E2E flows.
- Service worker testing demands careful event orchestration and may require specialized test utilities.
- CI environments require Xvfb or headed browser configurations since extensions do not fully load in headless mode.
- Mocking Chrome APIs enables isolated testing of extension logic without relying on actual browser storage or runtime behavior.
Combine these tools and patterns with the CI/CD pipeline setup and development workflows to build a robust testing infrastructure for your Chrome extension.
Related Articles
- CI/CD Pipeline for Extensions
- Chrome Extension Development Guide
- Extension Debugging Techniques
- Background Service Worker Patterns
Part of the Chrome Extension Guide by theluckystrike. More at zovo.one.