Chrome Extension End-to-End Testing Guide — Automated Browser Testing

15 min read

Chrome Extension End-to-End Testing Guide — Automated Browser Testing

End-to-end (E2E) testing verifies that your Chrome extension works correctly when installed and running in a real browser. While unit tests check individual functions in isolation, E2E tests validate the complete user experience—from clicking a button in the popup to observing the result in a content script on a webpage. This guide covers automated browser testing tools, patterns, and best practices for Chrome extensions.

Understanding E2E Testing for Extensions

Chrome extensions are fundamentally different from web applications. They run across multiple execution contexts, communicate via message passing, and have access to browser APIs unavailable to regular websites. Testing an extension end-to-end means verifying that all these pieces work together correctly.

An E2E test for a Chrome extension might:

  1. Load the extension into a test browser instance
  2. Open a target webpage
  3. Click the extension icon to open the popup
  4. Interact with popup UI elements
  5. Verify that the content script injected into the page behaves correctly

This comprehensive validation catches issues that unit tests miss, including timing bugs, context communication problems, and integration issues with real web pages.

Puppeteer vs Playwright for Extension Testing

Two tools dominate automated browser testing for Chrome extensions: Puppeteer and Playwright. Both are excellent choices, but understanding their differences helps you pick the right one for your project.

Puppeteer

Puppeteer is Google’s official tool for controlling Chrome/Chromium programmatically. It offers tight integration with Chrome features and is the most popular choice for extension testing.

Advantages:

Disadvantages:

Playwright

Playwright, developed by Microsoft, supports multiple browsers and offers modern APIs.

Advantages:

Disadvantages:

Tool Comparison

Feature Puppeteer Playwright
Browser Support Chromium only All major browsers
Extension Testing Excellent Good
Auto-waiting Manual Built-in
API Style Callback/Promise Async/Await native
Community Size Large Growing rapidly
Chrome Features Immediate access Good support

For most Chrome extension developers, Puppeteer remains the recommended choice due to its excellent extension testing support and Chrome-native features. However, if you need cross-browser testing, Playwright is the better option.

Setting Up Puppeteer for Extension Testing

Let’s set up a comprehensive E2E testing environment using Puppeteer.

Installation

npm install -D puppeteer
# Or for TypeScript
npm install -D puppeteer @types/puppeteer

Basic Extension Test Setup

Create a test file that loads your extension:

// test/e2e/basic-extension.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import puppeteer, { Browser, Page } from "puppeteer";
import path from "path";

describe("Extension E2E Tests", () => {
  let browser: Browser;
  let extensionId: string;
  const extensionPath = path.resolve(__dirname, "../../dist");

  beforeAll(async () => {
    browser = await puppeteer.launch({
      headless: false, // Set to true for CI
      args: [
        `--disable-extensions-except=${extensionPath}`,
        `--load-extension=${extensionPath}`,
        "--no-sandbox",
        "--disable-setuid-sandbox",
      ],
    });

    // Get the extension ID from the loaded extension
    const targets = await browser.targets();
    const extensionTarget = targets.find(
      (target) => target.type() === "service_worker"
    );
    const client = await extensionTarget!.createCDPSession();
    const info = await client.send("Manifest.getJSON");
    extensionId = extensionTarget!.url().split("/")[2];
  });

  afterAll(async () => {
    await browser.close();
  });
});

Testing the Extension Popup

The popup is the most visible part of your extension. E2E tests should verify that it loads correctly and responds to user interactions.

Testing Popup Interactions

// test/e2e/popup.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import puppeteer, { Browser } from "puppeteer";

describe("Popup Tests", () => {
  let browser: Browser;
  let popupPage: any;

  beforeAll(async () => {
    browser = await puppeteer.launch({
      args: ["--headless=new", "--no-sandbox"],
    });
  });

  afterAll(async () => {
    await browser.close();
  });

  it("should load popup with correct title", async () => {
    // Get all pages including the popup
    const targets = await browser.targets();
    const popupTarget = targets.find(
      (t) => t.type() === "page" && t.url().includes("popup.html")
    );

    popupPage = await popupTarget?.page();
    if (!popupPage) {
      // Open popup by clicking extension icon
      const page = await browser.newPage();
      await page.goto("https://example.com");
      
      // Click extension icon programmatically
      const extensionId = "your-extension-id";
      await page.goto(`chrome-extension://${extensionId}/popup.html`);
    }

    const title = await popupPage.title();
    expect(title).toBe("My Extension");
  });

  it("should save user settings when button clicked", async () => {
    // Navigate to popup
    const extensionId = "your-extension-id";
    const popupPage = await browser.newPage();
    await popupPage.goto(`chrome-extension://${extensionId}/popup.html`);

    // Fill in settings
    await popupPage.type("#username-input", "testuser");
    await popupPage.click("#save-button");

    // Verify storage was updated
    const storage = await popupPage.evaluate(() => {
      return new Promise((resolve) => {
        chrome.storage.local.get("username", (result) => {
          resolve(result);
        });
      });
    });

    expect(storage.username).toBe("testuser");
  });
});

Testing Content Script Injection

Content scripts run in the context of web pages. Testing them requires loading a target page and verifying that your script executes correctly.

Testing Content Script Behavior

// test/e2e/content-script.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import puppeteer, { Browser, Page } from "puppeteer";

describe("Content Script Tests", () => {
  let browser: Browser;

  beforeAll(async () => {
    browser = await puppeteer.launch({
      args: [
        "--headless=new",
        "--no-sandbox",
        `--disable-extensions-except=${path.resolve(__dirname, "../../dist")}`,
        `--load-extension=${path.resolve(__dirname, "../../dist")}`,
      ],
    });
  });

  afterAll(async () => {
    await browser.close();
  });

  it("should inject content script and modify page", async () => {
    const page = await browser.newPage();
    
    // Navigate to a test page
    await page.goto("https://example.com");

    // Wait for content script to execute
    await page.waitForSelector("[data-extension-injected]");

    // Verify content script added elements
    const hasInjectedElement = await page.evaluate(() => {
      return document.querySelector("[data-extension-injected]") !== null;
    });

    expect(hasInjectedElement).toBe(true);
  });

  it("should communicate between popup and content script", async () => {
    const page = await browser.newPage();
    await page.goto("https://example.com");

    // Get extension ID
    const targets = await browser.targets();
    const extensionTarget = targets.find(
      (t) => t.type() === "service_worker"
    );
    const extensionId = extensionTarget!.url().split("/")[2];

    // Send message from popup
    const popupPage = await browser.newPage();
    await popupPage.goto(`chrome-extension://${extensionId}/popup.html`);
    await popupPage.click("#inject-button");

    // Wait for content script to receive message
    await page.waitForTimeout(500);

    // Verify content script responded
    const messageReceived = await page.evaluate(() => {
      return (window as any).__extensionMessageReceived === true;
    });

    expect(messageReceived).toBe(true);
  });
});

Testing Background Service Workers

Background service workers handle events even when no popup or content script is active. E2E tests verify that the service worker responds correctly to Chrome events.

Testing Service Worker Events

// test/e2e/background-worker.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import puppeteer, { Browser } from "puppeteer";
import path from "path";

describe("Background Service Worker Tests", () => {
  let browser: Browser;

  beforeAll(async () => {
    browser = await puppeteer.launch({
      args: [
        "--headless=new",
        "--no-sandbox",
        `--disable-extensions-except=${path.resolve(__dirname, "../../dist")}`,
        `--load-extension=${path.resolve(__dirname, "../../dist")}`,
      ],
    });
  });

  afterAll(async () => {
    await browser.close();
  });

  it("should respond to messages from content scripts", async () => {
    // Get the service worker
    const targets = await browser.targets();
    const serviceWorkerTarget = targets.find(
      (t) => t.type() === "service_worker"
    );
    const swSession = await serviceWorkerTarget!.createCDPSession();

    // Navigate a page that sends messages
    const page = await browser.newPage();
    await page.goto("https://example.com");
    
    // Inject a script that sends messages to the service worker
    await page.evaluate(() => {
      chrome.runtime.sendMessage(
        { greeting: "hello" },
        (response) => {
          console.log("Response:", response);
        }
      );
    });

    // Verify service worker received and responded
    const messages = await swSession.evaluate(() => {
      return (self as any).__testMessages || [];
    });

    expect(messages).toContain("hello");
  });
});

Testing Cross-Context Communication

Chrome extensions use message passing between contexts. E2E tests verify that messages flow correctly between popup, background, and content scripts.

Message Passing Tests

// test/e2e/message-passing.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import puppeteer, { Browser } from "puppeteer";
import path from "path";

describe("Message Passing Tests", () => {
  let browser: Browser;
  let extensionId: string;

  beforeAll(async () => {
    const extensionPath = path.resolve(__dirname, "../../dist");
    browser = await puppeteer.launch({
      args: [
        "--headless=new",
        "--no-sandbox",
        `--disable-extensions-except=${extensionPath}`,
        `--load-extension=${extensionPath}`,
      ],
    });

    // Get extension ID
    const targets = await browser.targets();
    const swTarget = targets.find((t) => t.type() === "service_worker");
    extensionId = swTarget!.url().split("/")[2];
  });

  afterAll(async () => {
    await browser.close();
  });

  it("should send message from popup to content script", async () => {
    const page = await browser.newPage();
    await page.goto("https://example.com");

    const popupPage = await browser.newPage();
    await popupPage.goto(`chrome-extension://${extensionId}/popup.html`);

    // Click button in popup to send message to content script
    await popupPage.click("#send-to-content");

    // Wait for message to arrive
    await page.waitForTimeout(300);

    // Verify content script received message
    const received = await page.evaluate(() => {
      return (window as any).__receivedMessage;
    });

    expect(received).toBe("Hello from popup!");
  });
});

Automating Test Execution

Run your E2E tests as part of your CI/CD pipeline to catch regressions before releasing.

CI Configuration Example

# .github/workflows/e2e-tests.yml
name: E2E Tests

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

jobs:
  e2e-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"
          
      - name: Install dependencies
        run: npm ci
        
      - name: Build extension
        run: npm run build
        
      - name: Run E2E tests
        run: npm run test:e2e
        
      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: e2e-test-results
          path: test-results/

Best Practices for E2E Testing

Follow these guidelines for reliable, maintainable E2E tests:

  1. Test in a clean environment — Always launch a fresh browser instance for each test suite to avoid state pollution.

  2. Use meaningful selectors — Add data-testid attributes to your extension HTML for reliable element selection.

  3. Handle async operations — Use explicit waits instead of arbitrary timeouts. Puppeteer’s waitForSelector and evaluate handle most cases.

  4. Clean up after tests — Clear storage, remove listeners, and close pages to prevent test interference.

  5. Test real user flows — Your E2E tests should mirror how actual users interact with your extension.

  6. Run tests in headless mode for CI — Some features work differently in headless mode, so test in both modes during development.

  7. Monitor for flakes — Unstable tests erode trust. Fix or remove flaky tests immediately.

Conclusion

End-to-end testing is essential for building reliable Chrome extensions. By testing your extension in a real browser environment, you catch integration bugs that unit tests miss. Puppeteer provides excellent support for loading and testing Chrome extensions, while Playwright offers cross-browser capabilities if needed.

Invest in a comprehensive E2E test suite, and you’ll ship extensions with confidence, knowing that real users will have a smooth experience.

No previous article
No next article