service-worker-tests
Test service workers with Playwright (`context.serviceWorkers()` + `waitForEvent('serviceworker')`) and unit tests via `service-worker-mock`. Covers the MV3 service-worker lifecycle (~30s suspend), cache strategies (cache-first, network-first, stale-while-revalidate), and `evaluate()` continuity across worker restart. Use when a site registers a service worker and its caching / offline behavior is uncovered, or users report stale content surviving a deploy; for the install / add-to-homescreen flow use pwa-install-flow-tests, and to design (not test) the caching policy use sw-cache-strategy-author.
Install with skills.sh (any agent)
npx skills add testland/qa --skill service-worker-testsservice-worker-tests
Service workers are the heart of offline-capable PWAs and Chrome extension MV3 background scripts. Per the Playwright Chrome extensions docs (opens in new window), Playwright accesses service workers via context.serviceWorkers() and persists Worker objects across MV3's ~30s auto-suspend.
When to use
How to use
Setup - Playwright persistent context
import { test, expect, chromium } from '@playwright/test';
test('service worker registers on first load', async () => {
const userDataDir = '/tmp/test-user-data';
const context = await chromium.launchPersistentContext(userDataDir, {
headless: false, // needed for SW registration in some Chromium versions
});
const page = await context.newPage();
await page.goto('https://localhost:3000');
// Wait for the SW to register
let [serviceWorker] = context.serviceWorkers();
if (!serviceWorker) {
serviceWorker = await context.waitForEvent('serviceworker');
}
expect(serviceWorker.url()).toContain('/sw.js');
await context.close();
});Per Playwright Chrome extensions docs (opens in new window), the same context.serviceWorkers() pattern applies to PWA service workers (not just extensions).
Worked example - offline fallback end to end
One test that registers the worker, drops the network, and asserts the network-first fallback renders - the smallest complete offline check:
test('registered SW serves the offline page when network drops', async () => {
const context = await chromium.launchPersistentContext('/tmp/sw-offline', {
headless: false,
});
const page = await context.newPage();
// 1. Load online so the SW installs and pre-caches
await page.goto('https://localhost:3000');
let [sw] = context.serviceWorkers();
sw ??= await context.waitForEvent('serviceworker');
expect(sw.url()).toContain('/sw.js');
await page.waitForLoadState('networkidle');
// 2. Drop the network and reload
await context.setOffline(true);
await page.reload();
// 3. The SW's offline fallback answers instead of a network error
await expect(page.locator('text=You are offline')).toBeVisible();
await context.close();
});Evaluate in the worker context
const swVersion = await serviceWorker.evaluate(() => {
return self.SW_VERSION;
});
expect(swVersion).toBe('1.4.2');
// Inspect cache contents
const cachedUrls = await serviceWorker.evaluate(async () => {
const cache = await caches.open('app-v1');
const reqs = await cache.keys();
return reqs.map(r => r.url);
});
expect(cachedUrls).toContain('https://localhost:3000/manifest.json');evaluate() proxies through the worker's JS context. Per Playwright Chrome extensions docs (opens in new window), Playwright keeps the same Worker object alive across MV3 auto-suspend (~30s) - evaluate() calls continue transparently after restart.
Cache strategies
Cache-first:
test('cache-first returns from cache, no network', async ({ page }) => {
await page.goto('https://localhost:3000');
await page.waitForLoadState('networkidle');
// Block network to force cache hits
await page.route('**/static/**', route => route.abort('failed'));
await page.reload();
// Page still renders from SW cache
await expect(page.locator('h1')).toBeVisible();
});Network-first with offline fallback:
test('network-first falls back to offline page', async ({ page, context }) => {
await page.goto('https://localhost:3000');
await page.waitForLoadState('networkidle');
await context.setOffline(true);
await page.reload();
await expect(page.locator('text=You are offline')).toBeVisible();
});Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Test SW with chromium.launch() (incognito) | SWs don't register in incognito-by-default contexts | Use launchPersistentContext (Setup) |
Skip waitForEvent('serviceworker') | Race condition - serviceWorkers() returns empty before registration | Always await the event (Setup) |
| Reuse user-data-dir across test runs | Stale SW from prior run answers requests | Fresh userDataDir per test (Setup) |
| Test offline by killing dev server | SW caches still serve from network until setOffline(true) | Use context.setOffline(true) (Worked example) |
| Forget to grant notifications permission | pushManager.subscribe rejects silently | context.grantPermissions(['notifications']) (see references) |
Limitations
References
Advanced service worker tests
View source (opens in new window)Advanced service worker tests
Deep reference for the service-worker-tests SKILL.md. Consult for the deeper recipes past the persistent-context setup and offline worked example in the main skill: version-bump cache invalidation, service-worker-mock unit tests, and push-notification subscription tests.
Version bump + cache invalidation
Assert a v2 worker deletes the v1 caches when it activates:
test('SW v2 deletes v1 caches on activate', async ({ context, page }) => {
await page.goto('https://localhost:3000');
let sw = context.serviceWorkers()[0]
?? await context.waitForEvent('serviceworker');
const v1Caches = await sw.evaluate(() => caches.keys());
expect(v1Caches).toContain('app-v1');
// Trigger SW update (deploy v2 to test server)
await page.evaluate(() => navigator.serviceWorker.getRegistration().then(r => r?.update()));
// Wait for activation
await page.waitForFunction(() =>
navigator.serviceWorker.controller?.scriptURL.includes('v2')
);
const v2Caches = await sw.evaluate(() => caches.keys());
expect(v2Caches).toContain('app-v2');
expect(v2Caches).not.toContain('app-v1');
});Unit test the SW with service-worker-mock
For Jest/Vitest unit tests that don't need a browser:
npm install --save-dev service-worker-mockimport makeServiceWorkerEnv from 'service-worker-mock';
beforeEach(() => {
Object.assign(global, makeServiceWorkerEnv());
jest.resetModules();
});
test('install event opens cache and pre-caches assets', async () => {
await import('../src/sw.js');
await self.trigger('install');
expect(self.snapshot().caches['app-v1']).toBeDefined();
expect(self.snapshot().caches['app-v1']['/index.html']).toBeDefined();
});Push notification subscription test
test('push subscription created on registration', async ({ page, context }) => {
await context.grantPermissions(['notifications']);
await page.goto('https://localhost:3000');
const subscription = await page.evaluate(async () => {
const reg = await navigator.serviceWorker.ready;
return reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: '<base64-vapid-key>',
});
});
expect(subscription).toBeDefined();
});Pair with push-notification-test-author for downstream send/receive assertions.
References
Related skills
browser-extension-tests
Test Chromium browser extensions (MV3) with Playwright via `launchPersistentContext` + `--load-extension` / `--disable-extensions-except` flags. Cover service worker, popup pages, content scripts, message passing, and `chrome.runtime` API mocking. Service worker auto-suspends ~30s; Playwright keeps the Worker object alive across restarts. Use when a repo builds a Chromium extension (a `manifest.json` with `manifest_version: 3` and a built `dist/`) and its popup, content-script injection, background worker, or `chrome.storage` behavior needs automated coverage.
pwa-install-flow-tests
Test the Progressive Web App install flow (Web App Manifest validation, `beforeinstallprompt` event handling, installability criteria, install prompt UX). Covers desktop install badge, Android WebAPK minting, iOS Add to Home Screen, and the `appinstalled` event. Use when a site ships a `link rel="manifest"` and users must be able to install it - especially after a manifest, icon, or `start_url` edit that could silently drop install eligibility.
sw-cache-strategy-author
Author service worker cache strategies (cache-first, network-first, stale-while-revalidate, cache-only, network-only) per Workbox conventions, plus generate the matching Playwright assertions to lock the strategy in. Avoids the common "cached forever" pitfall by enforcing TTL + version-bump invalidation. Use when a new route needs defined offline behavior, when a hand-rolled `sw.js` is being moved to Workbox strategies, or when users report seeing stale content after a deploy.
web-vitals-inp-deep
Deep INP (Interaction to Next Paint) testing: decomposes input delay, processing duration, and presentation delay via the web-vitals/attribution build, asserts per-interaction INP budgets in Playwright using PerformanceObserver plus the web-vitals visibilitychange flush, and identifies long tasks blocking the main thread. Use when a page feels unresponsive while LCP and CLS are green, or to gate key interactions (form submit, modal open, route change) under an INP budget in CI. Covers interactions only: for service-worker cache-strategy latency use service-worker-tests.