workbox-tests
Test Workbox-built service workers - pin behavior of the named recipes (`pageCache`, `staticResourceCache`, `imageCache`, `googleFontsCache`, `offlineFallback`, `warmStrategyCache`) per [developer.chrome.com/docs/workbox/modules/workbox-recipes][wb-recipes]; validate `workbox-precaching` manifest injection (`__WB_MANIFEST` revisioning); assert `workbox-routing` route handler matches; assert `workbox-expiration` and `workbox-cacheable-response` plugin gates; and verify the `workbox-window` registration helper events (`installed`, `waiting`, `controlling`, `activated`). Use when a project already ships a Workbox-built service worker and its recipe behavior needs pinning against refactors or a version upgrade.
Install with skills.sh (any agent)
npx skills add testland/qa --skill workbox-testsworkbox-tests
Overview
This skill tests Workbox-built service workers - distinct from sw-cache-strategy-author, which authors the strategies. Here we assert that an already-shipped Workbox SW behaves the way its recipes claim, using the workbox-precaching / workbox-routing / workbox-strategies / workbox-recipes / workbox-window packages per developer.chrome.com/docs/workbox/modules (opens in new window).
Pinned version
Time-sensitive pin - re-check at wb-gh (opens in new window) on upgrade: Workbox v7.4.1 (released May 2026).
When to use
Authoring
Step 1 - Install test dependencies
Workbox ships no first-party test runner; the canonical pairing is Playwright (for runtime SW assertions) plus a unit-test runner (Vitest or Jest) for the workbox-window page-side helper:
npm install --save-dev @playwright/test vitest
# Workbox itself is already a runtime dep at this pointStep 2 - Decide where each assertion lives
| Subject | Runner | Why |
|---|---|---|
Precache manifest shape (__WB_MANIFEST) | Vitest reading the built sw.js artifact | Static; no browser needed |
Recipe behavior at runtime (pageCache, imageCache) | Playwright | Needs a real caches API + fetch interception |
workbox-window events on the page | Playwright (page side) | Listens on wb.addEventListener(...) from page code |
Plugin TTL / quota (workbox-expiration) | Playwright with clock manipulation | Needs the SW to actually call the plugin's pruning logic |
Step 3 - Author the precache-manifest static assertion
// tests/precache-manifest.spec.ts
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
describe('workbox-precaching manifest', () => {
it('emits the __WB_MANIFEST entries with revision strings', () => {
const sw = readFileSync('./dist/sw.js', 'utf8');
// workbox-precaching tokens; per wb-modules
expect(sw).toMatch(/precacheAndRoute\s*\(/);
// Build-tool injects __WB_MANIFEST as an array of { url, revision } records
const manifest = sw.match(/self\.__WB_MANIFEST\s*=\s*(\[[^;]+\])/)?.[1];
expect(manifest).toBeDefined();
const entries = JSON.parse(manifest!);
expect(Array.isArray(entries)).toBe(true);
for (const entry of entries) {
expect(typeof entry.url).toBe('string');
// Hashed filenames carry revision: null; non-hashed must have a revision string
const isHashed = /\.[a-f0-9]{8,}\./.test(entry.url);
if (!isHashed) expect(typeof entry.revision).toBe('string');
}
});
});precacheAndRoute() is the entry point exported from workbox-precaching per wb-modules (opens in new window) - it precaches a file set and manages updates to those files.
Step 4 - Author per-recipe runtime tests
Each named recipe has a documented default per wb-recipes (opens in new window); pin those defaults with tests. imageCache() is a cache-first strategy with defaults of 60 images cached for 30 days - pin the 60-entry cap:
import { test, expect } from '@playwright/test';
test('imageCache() applies the 60-entry default cap', async ({ context, page }) => {
await page.goto('https://localhost:3000/gallery');
await page.waitForLoadState('networkidle');
// Force 61 distinct image requests
for (let i = 0; i < 61; i++) {
await page.evaluate((n) => fetch(`/img/test-${n}.png`).catch(() => {}), i);
}
// Wait for ExpirationPlugin to prune (it runs async)
await page.waitForTimeout(500);
let [sw] = context.serviceWorkers();
if (!sw) sw = await context.waitForEvent('serviceworker');
const count = await sw.evaluate(async () => {
const cacheName = (await caches.keys()).find(k => k.includes('image'));
if (!cacheName) return 0;
return (await (await caches.open(cacheName)).keys()).length;
});
expect(count).toBeLessThanOrEqual(60);
});The other five recipe tests - pageCache() (network-first, 3s network-timeout default), offlineFallback() (offline.html default), googleFontsCache() (30 fonts / 1 year), staticResourceCache() (stale-while-revalidate), and warmStrategyCache() (warms declared URLs on install) - are in references/recipe-tests.md, each retaining its test-invariant default.
Step 5 - Author workbox-window event tests
workbox-window is the page-side companion for registering the SW, managing updates, and responding to lifecycle events per wb-modules (opens in new window). It emits installed, waiting, controlling, activated, and redundant. Listen on each from the page context:
test('wb.addEventListener installed fires after register()', async ({ page }) => {
await page.goto('https://localhost:3000/');
const events = await page.evaluate(() => new Promise<string[]>((resolve) => {
// @ts-expect-error workbox-window global from the page bundle
const wb = new window.Workbox('/sw.js');
const fired: string[] = [];
wb.addEventListener('installed', () => fired.push('installed'));
wb.addEventListener('waiting', () => fired.push('waiting'));
wb.addEventListener('controlling', () => fired.push('controlling'));
wb.addEventListener('activated', () => fired.push('activated'));
wb.register();
setTimeout(() => resolve(fired), 3000);
}));
// First install fires installed + activated; waiting only fires on update with a controller already present
expect(events).toContain('installed');
expect(events).toContain('activated');
});The five-event vocabulary is enumerated in wb-modules (opens in new window) under workbox-window.
Step 6 - Test the cacheable-response plugin gate
Per wb-modules (opens in new window), workbox-cacheable-response restricts which requests are cached by response status code or headers. A common config is statuses: [200]. Assert that a 404 is not cached:
test('CacheableResponsePlugin excludes non-200 from cache', async ({ context, page }) => {
await page.goto('https://localhost:3000/');
await page.evaluate(() => fetch('/api/known-404').catch(() => {}));
await page.waitForTimeout(300);
let [sw] = context.serviceWorkers();
if (!sw) sw = await context.waitForEvent('serviceworker');
const cachedKnown404 = await sw.evaluate(async () => {
for (const name of await caches.keys()) {
const c = await caches.open(name);
for (const req of await c.keys()) {
if (req.url.endsWith('/api/known-404')) return true;
}
}
return false;
});
expect(cachedKnown404).toBe(false);
});Running
Locally
npm run build # produces dist/sw.js with __WB_MANIFEST injected
npx vitest run tests/precache-manifest.spec.ts
npx playwright test tests/workbox-recipes.spec.tsThe build step is non-optional - workbox-precaching only emits the precache manifest at build time per wb-modules (opens in new window) (workbox-build / workbox-webpack-plugin / workbox-cli).
In CI
jobs:
workbox-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build
- run: npx playwright install --with-deps chromium
- run: npx vitest run tests/precache-manifest.spec.ts
- run: npx playwright test tests/workbox-recipes.spec.tsThe unit (Vitest) step gates fast; the Playwright recipe step catches the runtime-only regressions.
Parsing results
Workbox runtime caches are observable via three surfaces:
| Surface | What it shows | How to read |
|---|---|---|
caches.keys() | All cache namespaces (e.g. workbox-precache-v2, pages, images) | sw.evaluate(() => caches.keys()) |
caches.open(name).keys() | URLs cached in a namespace | Filter by URL pattern to assert what the recipe captured |
Playwright page.on('request') | Network egress per request | Empty for cache-hit served paths = recipe working |
When an assertion fails on the cache-content surface, also check the namespace name: Workbox v7 uses workbox-precache-v2 for precaching and recipe-default names (pages, images, static-resources, google-fonts-stylesheets, google-fonts-webfonts) for recipes unless overridden via cacheName option per wb-recipes (opens in new window).
CI integration
For projects that ship Workbox: lock both the precache manifest and one runtime recipe behavior per PR.
- name: Workbox unit + e2e
run: |
npm run build
npx vitest run tests/precache-manifest.spec.ts
npx playwright test tests/workbox-recipes.spec.tsFor projects that publish a service worker as part of a release artifact (separate from app deploy), gate the release on the same two steps - a Workbox regression that escapes to prod usually manifests as stale-forever or never-installed, both invisible without test coverage.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Assert recipe behavior only on first page load | Cache is empty; SW hasn't been installed yet | Pre-warm by visiting twice (or use await page.waitForLoadState('networkidle')) |
Assert caches.keys() includes a fixed namespace name | Per wb-recipes (opens in new window), default names can be overridden via cacheName | Match by suffix substring (name.endsWith('-precache-v2')) |
Use Vitest with a JSDOM-mocked caches for recipe behavior | JSDOM does not implement Cache Storage faithfully; ServiceWorkerRegistration is absent | Use Playwright for runtime recipe assertions (Step 4) |
Assume precacheAndRoute(self.__WB_MANIFEST) works without a bundler | __WB_MANIFEST is injected at build time per wb-modules (opens in new window); CDN-served workbox-sw skips it | If using workbox-sw (CDN loader per wb-modules (opens in new window)), drop the precache assertion |
Test 60-entry cap by checking caches.match returns | ExpirationPlugin prunes async; tight await returns stale state | Add await page.waitForTimeout(500) after the trigger (Step 4 imageCache() test) |
Skip the workbox-window event tests entirely | The page-side "update available" UX is built on these events; breaks silently | Step 5 covers the five-event vocabulary |
Limitations
References
Per-recipe runtime test templates
View source (opens in new window)Per-recipe runtime test templates
Companion detail for workbox-tests. The imageCache() 60-entry-cap test is the representative core inline in Step 4; these are the other five recipe templates. Each retains the recipe's test-invariant default (timeout, entry count, TTL) but drops the verbatim doc prose. Defaults are the Workbox v7.x values per wb-recipes - re-pin at the recipe page on a major upgrade.
pageCache()
Network-first for HTML navigations with a 3-second networkTimeoutSeconds default - cache serves once network exceeds the timeout:
import { test, expect } from '@playwright/test';
test('pageCache() falls back to cache when network exceeds 3s', async ({ page, context }) => {
await page.goto('https://localhost:3000/');
await page.waitForLoadState('networkidle');
// Slow the network past the 3s networkTimeoutSeconds default
await context.route('**/*.html', async route => {
await new Promise(r => setTimeout(r, 5_000));
await route.continue();
});
await page.goto('https://localhost:3000/');
// Cached shell should serve before the 5s slow network resolves
await expect(page.locator('h1')).toBeVisible({ timeout: 4_500 });
});offlineFallback()
Serves the offline.html default on a navigation routing error while offline (swap the target if the project overrides pageFallback):
test('offlineFallback() serves offline.html on navigation failure', async ({ page, context }) => {
await page.goto('https://localhost:3000/');
await page.waitForLoadState('networkidle');
await context.setOffline(true);
const resp = await page.goto('https://localhost:3000/never-cached');
expect(resp?.status()).toBe(200);
await expect(page.locator('text=/offline/i')).toBeVisible();
});googleFontsCache()
Stale-while-revalidate for stylesheets, cache-first for font files, with defaults of 30 font files cached for one year:
test('googleFontsCache stylesheet uses stale-while-revalidate', async ({ page, context }) => {
await page.goto('https://localhost:3000/');
await page.waitForLoadState('networkidle');
await context.setOffline(true);
const status = await page.evaluate(() =>
fetch('https://fonts.googleapis.com/css2?family=Inter').then(r => r.status).catch(() => 0)
);
// Stale cache must respond offline
expect(status).toBe(200);
});staticResourceCache()
Stale-while-revalidate for CSS, JavaScript, and Web Worker requests:
test('staticResourceCache serves cached CSS offline', async ({ page, context }) => {
await page.goto('https://localhost:3000/');
await page.waitForLoadState('networkidle');
await context.setOffline(true);
const status = await page.evaluate(() =>
fetch('/styles/app.css').then(r => r.status).catch(() => 0)
);
expect(status).toBe(200);
});warmStrategyCache()
Loads the declared URL list into the cache during the SW install phase - pin which URLs are warmed:
test('warmStrategyCache() warms the declared URL list on install', async ({ context, page }) => {
await page.goto('https://localhost:3000/');
let [sw] = context.serviceWorkers();
if (!sw) sw = await context.waitForEvent('serviceworker');
// SW install phase warms a known URL - pin it
const warmed = await sw.evaluate(async () => {
const names = await caches.keys();
for (const n of names) {
const cache = await caches.open(n);
const keys = await cache.keys();
if (keys.some(k => k.url.endsWith('/critical-data.json'))) return true;
}
return false;
});
expect(warmed).toBe(true);
});Source reference:
Related skills
add-to-homescreen-flow-tests
Build-an-X workflow that emits the Add-to-Home-Screen / install-flow test suite. Walks the four-stage install timeline (gate → `beforeinstallprompt` handshake → per-platform path → `display-mode` MQ), emits one test per gate cell per [web.dev/articles/install-criteria][install-criteria], the deferred-prompt → `prompt()` → `userChoice` chain per [web.dev/articles/customize-install][customize-install], the iOS Safari manual-metadata branch (`apple-touch-icon`, `apple-mobile-web-app-capable`) per [web.dev/learn/pwa/installation][learn-pwa], and the post-install `(display-mode: standalone)` MQ assertion. Output: a Playwright spec file with per-stage cells plus the iOS metadata spec, plus a coverage matrix mapping each install criterion to its assertion. Use when a PWA's manifest, icons, or install handler change, or when install conversion drops and it is unclear which install stage users fall out at.
lighthouse-pwa-audit
Run and interpret Lighthouse PWA audits - even after the PWA *category* was deprecated per [developer.chrome.com/docs/lighthouse/pwa][lh-pwa], the individual audits (`installable-manifest`, `service-worker`, `splash-screen`, `themed-omnibox`, `viewport`, `content-width`, `apple-touch-icon`, `maskable-icon`) still run and report under a custom Lighthouse config or via direct audit invocation. Covers CLI flags (`--only-categories`, `--output`, `--form-factor`, `--throttling-method`), programmatic Node.js invocation, Lighthouse CI assertions (`categories:{id}`, `audit-id` thresholds), and LHR JSON parsing. Use when a manifest or icon change needs a precise installable-manifest verdict, or when CI must gate PRs on PWA audit scores despite the category badge being gone.
offline-fallback-tests
Build-an-X workflow that emits the offline-fallback test suite. Walks the eight Jake Archibald offline recipes per [web.dev/articles/offline-cookbook][off-cookbook] (`Cache only`, `Network only`, `Cache, falling back to network`, `Cache and network race`, `Network falling back to cache`, `Cache then network`, `Generic fallback`, `Service Worker side templating`), maps each recipe to its assertion shape, layers the Workbox `offlineFallback()` recipe per [developer.chrome.com/docs/workbox/modules/workbox-recipes][wb-recipes], and pins the offline storage strategy (Cache Storage vs IndexedDB vs Storage Manager `persist()`/`estimate()`) per [web.dev/learn/pwa/offline-data][off-data]. Output: a Playwright spec file with one test per route's chosen recipe + a coverage matrix mapping recipes to URL patterns. Use when a route's SW caching behavior is being chosen or changed, or when a "doesn't work offline" report needs the failing route's recipe localized.
pwa-install-flow-reference
Pure reference for the PWA install flow as a test surface - the installability gate (manifest required fields per [install-criteria], registered service worker, HTTPS, ~30s user engagement), the `beforeinstallprompt` handshake (preventDefault → stash → prompt() on gesture → userChoice → appinstalled), and the `display-mode` post-install signal, with a how-to-use walkthrough and a worked Chromium install-flow assertion; per-platform paths (Android WebAPK, iOS Share menu, Firefox no-op) live in references/. Use when authoring or triaging install-flow assertions and you need the gate fields, the event contract, and the per-platform expectations in one place instead of re-reading three vendor docs.
service-worker-lifecycle-tests
Build-an-X workflow that emits per-SW state-transition tests covering the six `ServiceWorkerState` values per [w3c-github-io/ServiceWorker][sw-spec] (`parsed → installing → installed → activating → activated → redundant`), the `install` / `activate` / `fetch` event handlers per [MDN Service Worker API][mdn-sw], `event.waitUntil()` lifetime extension, `ServiceWorkerGlobalScope.skipWaiting()` and `Clients.claim()` upgrade-path semantics, the `statechange` event on `ServiceWorker` objects, `ServiceWorkerRegistration.update()`, and `navigator.serviceWorker.controller` checks. Output: a Playwright spec file with one test per transition plus a clean upgrade-path test (v1 active → v2 installed/waiting → v2 activated, with claim()). Use when authoring the baseline lifecycle spec, or when a deploy leaves users stuck on the old service worker - scoped to the state machine and the `skipWaiting` / `clients.claim` upgrade path, not to general service-worker assertion or cache-strategy patterns.
web-push-tests
Test the browser web-push subscription lifecycle - `pushManager.subscribe({ userVisibleOnly, applicationServerKey })` per [W3C Push API][w3c-push] returning a `PushSubscription` with `endpoint` + `keys.p256dh` + `keys.auth` + optional `expirationTime`; the `pushsubscriptionchange` service-worker event on refresh / revoke / expiry; the `push` event delivery with `PushMessageData`; VAPID auth per RFC 8292 (ES256 JWT, `aud` / `exp` ≤ 24h / `sub`); RFC 8030 push-service responses (201 Created, 410 Gone for expired endpoints, 413 Payload Too Large, 429); and `unsubscribe()` cleanup. Use when a PWA ships web-push and the subscription lifecycle needs release-gate coverage - scoped to the browser Push API, not to cross-channel delivery over native APNs / FCM.