Manifest V3’s architectural changes—particularly service workers replacing persistent background pages—introduce new testing challenges. This guide covers comprehensive testing strategies to ensure your MV3 extensions work reliably across browsers and scenarios.
MV3 Testing Challenges
Manifest V3 introduces unique testing challenges:
- Service Worker Lifecycle: Workers can terminate unexpectedly
- State Persistence: No persistent in-memory state
- Async Everything: All APIs are promise-based
- Limited DOM Access: No document in service workers
- Cross-Context Communication: Message passing between contexts
Testing Priority:
- Service worker wake/sleep cycles
- State recovery after worker termination
- Message passing reliability
- Permission flows (optional permissions)
- Content script injection timing
Test Environment Setup
Playwright Configuration
// playwright.config.js
const { defineConfig, devices } = require('@playwright/test');
module.exports = defineConfig({
testDir: './tests',
timeout: 30000,
retries: 2,
workers: 1, // Extensions require sequential execution
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
// Extension testing requires persistent context
contextOptions: {
// Will be overridden in test setup
}
}
}
]
});
Test Fixtures
// tests/fixtures.js
const { test: base, chromium } = require('@playwright/test');
const path = require('path');
const test = base.extend({
context: async ({}, use) => {
const extensionPath = path.join(__dirname, '../dist');
const context = await chromium.launchPersistentContext('', {
headless: false, // Extensions require headed mode
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`
]
});
// Get extension ID
let extensionId;
const serviceWorker = context.serviceWorkers()[0];
if (serviceWorker) {
extensionId = serviceWorker.url().split('/')[2];
} else {
// Wait for service worker to start
const sw = await context.waitForEvent('serviceworker');
extensionId = sw.url().split('/')[2];
}
context.extensionId = extensionId;
await use(context);
await context.close();
},
extensionPage: async ({ context }, use) => {
const page = await context.newPage();
await page.goto(`chrome-extension://${context.extensionId}/popup.html`);
await use(page);
}
});
module.exports = { test };
Service Worker Testing
Lifecycle Tests
// tests/service-worker.spec.js
const { test } = require('./fixtures');
test.describe('Service Worker Lifecycle', () => {
test('should wake up on message', async ({ context }) => {
// Kill service worker
const workers = context.serviceWorkers();
for (const worker of workers) {
await worker.evaluate(() => self.close());
}
// Verify it's stopped
await context.waitForEvent('serviceworker', {
predicate: () => context.serviceWorkers().length === 0,
timeout: 5000
}).catch(() => {}); // May already be stopped
// Trigger wake-up via extension page
const page = await context.newPage();
await page.goto(`chrome-extension://${context.extensionId}/popup.html`);
// Click something that sends a message to background
await page.click('#trigger-background-action');
// Verify service worker restarted
await context.waitForEvent('serviceworker');
const workers2 = context.serviceWorkers();
expect(workers2.length).toBeGreaterThan(0);
});
test('should preserve state after restart', async ({ context }) => {
const sw = context.serviceWorkers()[0];
// Set some state
await sw.evaluate(async () => {
await chrome.storage.local.set({ testKey: 'testValue' });
});
// Kill and restart
await sw.evaluate(() => self.close());
await context.waitForEvent('serviceworker');
// Verify state persisted
const newSw = context.serviceWorkers()[0];
const state = await newSw.evaluate(async () => {
return chrome.storage.local.get('testKey');
});
expect(state.testKey).toBe('testValue');
});
test('should handle rapid wake/sleep cycles', async ({ context }) => {
const results = [];
for (let i = 0; i < 5; i++) {
// Trigger action
const page = await context.newPage();
await page.goto(`chrome-extension://${context.extensionId}/popup.html`);
const result = await page.evaluate(async () => {
const response = await chrome.runtime.sendMessage({ type: 'PING' });
return response;
});
results.push(result);
await page.close();
// Force worker termination
const sw = context.serviceWorkers()[0];
if (sw) await sw.evaluate(() => self.close());
await new Promise(r => setTimeout(r, 100));
}
// All pings should have succeeded
expect(results.every(r => r.success)).toBe(true);
});
});
Alarm and Event Tests
test.describe('Alarms and Events', () => {
test('should fire alarms correctly', async ({ context }) => {
const sw = context.serviceWorkers()[0];
// Create an alarm
await sw.evaluate(async () => {
await chrome.alarms.create('test-alarm', { delayInMinutes: 0.017 }); // ~1 second
});
// Wait for alarm to fire
const firedAlarm = await sw.evaluate(() => {
return new Promise(resolve => {
chrome.alarms.onAlarm.addListener((alarm) => {
resolve(alarm.name);
});
});
});
expect(firedAlarm).toBe('test-alarm');
});
test('should handle tab events', async ({ context }) => {
const sw = context.serviceWorkers()[0];
// Set up event listener
const eventPromise = sw.evaluate(() => {
return new Promise(resolve => {
chrome.tabs.onCreated.addListener((tab) => {
resolve({ id: tab.id, url: tab.pendingUrl || tab.url });
});
});
});
// Create a new tab
const page = await context.newPage();
await page.goto('https://example.com');
const event = await eventPromise;
expect(event.url).toContain('example.com');
await page.close();
});
});
Content Script Testing
// tests/content-script.spec.js
const { test } = require('./fixtures');
test.describe('Content Script', () => {
test('should inject into matching pages', async ({ context }) => {
const page = await context.newPage();
await page.goto('https://example.com');
// Wait for content script to inject
await page.waitForFunction(() => {
return window.__EXTENSION_LOADED__ === true;
}, { timeout: 5000 });
const injected = await page.evaluate(() => window.__EXTENSION_LOADED__);
expect(injected).toBe(true);
});
test('should communicate with background', async ({ context }) => {
const page = await context.newPage();
await page.goto('https://example.com');
// Content script should be able to send messages
const response = await page.evaluate(async () => {
return chrome.runtime.sendMessage({ type: 'GET_DATA' });
});
expect(response).toBeDefined();
expect(response.success).toBe(true);
});
test('should modify page DOM correctly', async ({ context }) => {
const page = await context.newPage();
await page.goto('https://example.com');
// Wait for extension modifications
await page.waitForSelector('.extension-injected-element');
const element = await page.$('.extension-injected-element');
expect(element).not.toBeNull();
});
test('should handle dynamic content', async ({ context }) => {
const page = await context.newPage();
await page.goto('https://example.com');
// Simulate dynamic content addition
await page.evaluate(() => {
const div = document.createElement('div');
div.className = 'dynamic-content';
div.textContent = 'Dynamic';
document.body.appendChild(div);
});
// Wait for extension to process new content
await page.waitForFunction(() => {
const dynamic = document.querySelector('.dynamic-content');
return dynamic?.hasAttribute('data-extension-processed');
});
});
});
Content Script Testing Tips:
- Use
waitForFunctionto detect injection - Test on multiple domains to verify matches
- Test with slow network conditions
- Verify cleanup on navigation
State Management Testing
// tests/state.spec.js
const { test } = require('./fixtures');
test.describe('State Management', () => {
test('should persist state across sessions', async ({ context }) => {
const sw = context.serviceWorkers()[0];
// Save state
await sw.evaluate(async () => {
await chrome.storage.local.set({
settings: { theme: 'dark', notifications: true },
userData: { name: 'Test User' }
});
});
// Close and reopen extension page
const page = await context.newPage();
await page.goto(`chrome-extension://${context.extensionId}/popup.html`);
// Verify state was loaded
const state = await page.evaluate(async () => {
return chrome.storage.local.get(['settings', 'userData']);
});
expect(state.settings.theme).toBe('dark');
expect(state.userData.name).toBe('Test User');
});
test('should sync state between contexts', async ({ context }) => {
// Set state from popup
const popup = await context.newPage();
await popup.goto(`chrome-extension://${context.extensionId}/popup.html`);
await popup.evaluate(async () => {
await chrome.storage.local.set({ sharedValue: 42 });
});
// Verify service worker sees the change
const sw = context.serviceWorkers()[0];
const value = await sw.evaluate(async () => {
const result = await chrome.storage.local.get('sharedValue');
return result.sharedValue;
});
expect(value).toBe(42);
});
test('should handle storage quota limits', async ({ context }) => {
const sw = context.serviceWorkers()[0];
// Try to store more than sync storage limit (100KB)
const largeData = 'x'.repeat(200 * 1024); // 200KB
const result = await sw.evaluate(async (data) => {
try {
await chrome.storage.sync.set({ large: data });
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
}, largeData);
expect(result.success).toBe(false);
expect(result.error).toContain('QUOTA');
});
});
Permission Testing
// tests/permissions.spec.js
const { test } = require('./fixtures');
test.describe('Permissions', () => {
test('should request optional permissions', async ({ context }) => {
const page = await context.newPage();
await page.goto(`chrome-extension://${context.extensionId}/options.html`);
// Click button that requests optional permission
const permissionPromise = context.waitForEvent('dialog').catch(() => null);
await page.click('#request-history-permission');
// Handle permission dialog if shown
const dialog = await permissionPromise;
if (dialog) {
await dialog.accept();
}
// Verify permission was granted
const hasPermission = await page.evaluate(async () => {
return chrome.permissions.contains({ permissions: ['history'] });
});
expect(hasPermission).toBe(true);
});
test('should handle permission denial gracefully', async ({ context }) => {
const page = await context.newPage();
await page.goto(`chrome-extension://${context.extensionId}/options.html`);
// Deny the permission request
context.on('dialog', dialog => dialog.dismiss());
await page.click('#request-history-permission');
// Verify graceful handling
const errorMessage = await page.textContent('#permission-status');
expect(errorMessage).toContain('Permission denied');
});
test('should check permission before using API', async ({ context }) => {
const sw = context.serviceWorkers()[0];
const result = await sw.evaluate(async () => {
// Should check permission first
const hasPermission = await chrome.permissions.contains({
permissions: ['history']
});
if (!hasPermission) {
return { error: 'Permission not granted' };
}
const history = await chrome.history.search({ text: '', maxResults: 10 });
return { history };
});
// Result should indicate permission status
expect(result.error || result.history).toBeDefined();
});
});
Cross-Browser Testing
// tests/cross-browser.spec.js
const { test, chromium, firefox } = require('@playwright/test');
const path = require('path');
const browsers = [
{ name: 'Chrome', launch: chromium, path: '../dist/chrome' },
{ name: 'Firefox', launch: firefox, path: '../dist/firefox' }
];
for (const browser of browsers) {
test.describe(`${browser.name} Compatibility`, () => {
let context;
test.beforeAll(async () => {
const extensionPath = path.join(__dirname, browser.path);
if (browser.name === 'Chrome') {
context = await browser.launch.launchPersistentContext('', {
headless: false,
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`
]
});
} else if (browser.name === 'Firefox') {
// Firefox requires different setup
context = await browser.launch.launchPersistentContext('', {
headless: false,
args: [`-install-extension=${extensionPath}`]
});
}
});
test.afterAll(async () => {
await context?.close();
});
test('should load popup', async () => {
const pages = context.pages();
expect(pages.length).toBeGreaterThan(0);
});
test('should use storage API', async () => {
const page = await context.newPage();
// Browser-agnostic test using polyfilled API
const result = await page.evaluate(async () => {
const api = typeof browser !== 'undefined' ? browser : chrome;
await api.storage.local.set({ test: 'value' });
const data = await api.storage.local.get('test');
return data.test;
});
expect(result).toBe('value');
});
});
}
Visual Regression Testing
// tests/visual.spec.js
const { test } = require('./fixtures');
test.describe('Visual Regression', () => {
test('popup should match snapshot', async ({ extensionPage }) => {
await extensionPage.waitForLoadState('networkidle');
await expect(extensionPage).toHaveScreenshot('popup-default.png');
});
test('popup dark mode should match snapshot', async ({ extensionPage }) => {
await extensionPage.evaluate(() => {
document.documentElement.setAttribute('data-theme', 'dark');
});
await expect(extensionPage).toHaveScreenshot('popup-dark.png');
});
test('options page should match snapshot', async ({ context }) => {
const page = await context.newPage();
await page.goto(`chrome-extension://${context.extensionId}/options.html`);
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot('options.png');
});
});
CI/CD Integration
# .github/workflows/test.yml
name: Extension Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Build extension
run: npm run build
- name: Install Playwright
run: npx playwright install --with-deps chromium
- name: Run tests
run: npm test
env:
CI: true
- name: Upload test results
uses: actions/upload-artifact@v3
if: always()
with:
name: test-results
path: test-results/
Summary
Comprehensive MV3 testing requires attention to service worker lifecycle, state persistence, cross-context communication, and browser compatibility. Automated tests catch regressions early and ensure reliability across the unique challenges of extension development.
Key testing areas:
- Service worker wake/sleep cycles
- State persistence and recovery
- Content script injection timing
- Permission flows
- Cross-browser compatibility
- Visual regression