Testing Firefox Extensions with Playwright: End-to-End Testing Guide
Extension testing is one of those things everyone knows they should do but few actually do. I've been using Playwright for end-to-end tests on the Weather & Clock Dashboard extension and it's changed how I think about extension quality.
Why E2E Testing for Extensions?
Unit tests don't cover the biggest failure modes:
- Does the extension actually load in Firefox?
- Does the new tab override work?
- Does dark mode actually change the theme?
- Does the weather display when location is set?
E2E tests catch all of these.
Setup: Playwright with Firefox Extensions
npm install --save-dev @playwright/test
npx playwright install firefox
playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
import path from 'path';
const EXTENSION_PATH = path.resolve(__dirname, '.');
export default defineConfig({
testDir: './tests',
use: {
browserName: 'firefox',
},
projects: [
{
name: 'firefox-extension',
use: {
...devices['Desktop Firefox'],
launchOptions: {
args: [
`-load-extension=${EXTENSION_PATH}`,
'-extension-arg',
]
}
}
}
]
});
Note: Firefox extension loading in Playwright uses a different API than Chrome. Here's the Firefox-specific approach:
import { chromium, firefox } from 'playwright';
async function launchFirefoxWithExtension(extensionPath: string) {
const browser = await firefox.launch({
headless: false, // Firefox requires headful for extensions in dev mode
firefoxUserPrefs: {
'extensions.autoDisableScopes': 0,
'extensions.enabledScopes': 15,
}
});
const context = await browser.newContext();
// Load extension
await context.addInitScript(() => {
// Extension-specific initialization
});
return { browser, context };
}
Simpler Approach: Test the HTML Directly
For new tab extensions, the most reliable approach is to load the HTML file directly in tests:
import { test, expect } from '@playwright/test';
import path from 'path';
const NEWTAB_URL = `file://${path.resolve(__dirname, '../newtab.html')}`;
test('renders weather widget', async ({ page }) => {
// Mock the weather API
await page.route('**/api.openweathermap.org/**', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
name: 'San Francisco',
sys: { country: 'US' },
weather: [{ main: 'Clear', description: 'clear sky', icon: '01d' }],
main: { temp: 72, feels_like: 70, humidity: 45 },
})
});
});
// Set city in localStorage
await page.addInitScript(() => {
localStorage.setItem('city', 'San Francisco');
});
await page.goto(NEWTAB_URL);
await page.waitForTimeout(1500);
// Assertions
await expect(page.locator('#weather-temp')).toContainText('72');
await expect(page.locator('#weather-city')).toContainText('San Francisco');
await expect(page.locator('#weather-description')).toContainText('clear sky');
});
Testing Dark Mode
test('dark mode toggle persists', async ({ page }) => {
await page.goto(NEWTAB_URL);
// Initially light mode
await expect(page.locator('body')).not.toHaveClass(/dark/);
// Toggle dark mode
await page.click('#theme-toggle');
await page.waitForTimeout(300);
// Should be dark
await expect(page.locator('body')).toHaveClass(/dark/);
// Reload — should persist
await page.reload();
await page.waitForTimeout(500);
await expect(page.locator('body')).toHaveClass(/dark/);
});
Testing the World Clock
test('world clock shows correct city', async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem('worldClocks', JSON.stringify([
{ label: 'Tokyo', timezone: 'Asia/Tokyo' },
{ label: 'London', timezone: 'Europe/London' }
]));
});
await page.goto(NEWTAB_URL);
await page.waitForTimeout(1000);
const clocks = await page.locator('.world-clock').all();
expect(clocks).toHaveLength(2);
await expect(clocks[0]).toContainText('Tokyo');
await expect(clocks[1]).toContainText('London');
// Both should show a valid time (HH:MM format)
const tokyoTime = await clocks[0].locator('.clock-time').textContent();
expect(tokyoTime).toMatch(/\d{1,2}:\d{2}/);
});
Testing Offline Behavior
test('shows cached data when offline', async ({ page }) => {
// First, cache some data
await page.addInitScript(() => {
const cachedWeather = {
data: { name: 'Cached City', main: { temp: 65 } },
timestamp: Date.now() - 1000 // 1 second ago
};
localStorage.setItem('cache_weather', JSON.stringify(cachedWeather));
});
// Simulate offline by blocking API requests
await page.route('**/api.openweathermap.org/**', route => {
route.abort('failed');
});
await page.goto(NEWTAB_URL);
await page.waitForTimeout(2000);
// Should show cached data
await expect(page.locator('#weather-city')).toContainText('Cached City');
await expect(page.locator('#weather-temp')).toContainText('65');
// Should show offline indicator
const status = await page.locator('#weather-status').textContent();
expect(status?.toLowerCase()).toContain('offline');
});
Running Tests
// package.json
{
"scripts": {
"test": "playwright test",
"test:ui": "playwright test --ui",
"test:headed": "playwright test --headed"
}
}
npm test # Run all tests headlessly
npm run test:headed # Run with visible browser
npm run test:ui # Interactive UI mode
npx playwright test --debug # Step through tests
CI Integration
# .github/workflows/test.yml
- name: Run E2E tests
run: |
npx playwright install firefox
npm test
For the Weather & Clock Dashboard, these tests run on every PR before merging.
Install the extension: Weather & Clock Dashboard on AMO
Part of a series on building Firefox browser extensions.
Top comments (0)