Testland
Browse all skills & agents

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-tests
View source

service-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

  • Testing offline-first behavior (cache-first responses, queue requests during offline).
  • Validating cache invalidation on service worker version bump.
  • Testing message passing between page and service worker.
  • Verifying push notification subscription registration.

How to use

  1. Launch a persistent context so the worker registers (incognito-default contexts skip SW registration) and await waitForEvent('serviceworker') before asserting - see Setup.
  2. Assert one lifecycle or offline behavior end to end - see Worked example.
  3. Inspect worker state (SW_VERSION, cache keys) with serviceWorker.evaluate(), which survives MV3's ~30s suspend.
  4. Cover the caching contract: cache-first hits and network-first offline fallback (see Cache strategies).
  5. For version-bump cache invalidation, service-worker-mock unit tests, and push-subscription tests, see references/advanced-service-worker-tests.md.

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-patternWhy it failsFix
Test SW with chromium.launch() (incognito)SWs don't register in incognito-by-default contextsUse launchPersistentContext (Setup)
Skip waitForEvent('serviceworker')Race condition - serviceWorkers() returns empty before registrationAlways await the event (Setup)
Reuse user-data-dir across test runsStale SW from prior run answers requestsFresh userDataDir per test (Setup)
Test offline by killing dev serverSW caches still serve from network until setOffline(true)Use context.setOffline(true) (Worked example)
Forget to grant notifications permissionpushManager.subscribe rejects silentlycontext.grantPermissions(['notifications']) (see references)

Limitations

  • WebKit (Safari) and Firefox have different SW APIs; this skill targets Chromium-channel testing primarily.
  • Playwright service worker support is documented as experimental-stable; check current API status at the Playwright Chrome extensions docs (opens in new window) for breaking changes.
  • service-worker-mock does not implement all Workbox APIs - for Workbox-using SWs, integration tests via Playwright are required.

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-mock
import 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.