Testing Manifest V3 Quality Automation

Comprehensive Testing Strategies for Manifest V3 Extensions

E
Extendable Team
· 14 min read

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 waitForFunction to 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