Testland
Browse all skills & agents

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`). For generic service-worker tests, install-flow tests, and SW cache-strategy authoring see `qa-modern-web/service-worker-tests`, `pwa-install-flow-tests`, and `sw-cache-strategy-author`. For channel-agnostic push-notification harness see `qa-notifications/push-notification-test-author`. This plugin covers Workbox recipes, offline-fallback patterns, Lighthouse PWA audit interpretation, and web-push subscription lifecycle.

workbox-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. Pinned release line at time of authoring: v7.4.1 per github.com/GoogleChrome/workbox (released May 2026).

When to use

  • A PWA already uses Workbox and tests need to lock its behavior against future refactors.
  • Migrating from Workbox v6 → v7 - assert each recipe behaves the same on the new release.
  • A "stale forever" bug report - pin the workbox-expiration plugin's TTL with a test before patching.
  • A workbox-precaching injection drifted (build emits wrong __WB_MANIFEST) - assert the precache manifest shape in CI.

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 point

Step 2 - Decide where each assertion lives

SubjectRunnerWhy
Precache manifest shape (__WB_MANIFEST)Vitest reading the built sw.js artifactStatic; no browser needed
Recipe behavior at runtime (pageCache, imageCache)PlaywrightNeeds a real caches API + fetch interception
workbox-window events on the pagePlaywright (page side)Listens on wb.addEventListener(...) from page code
Plugin TTL / quota (workbox-expiration)Playwright with clock manipulationNeeds 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: "Easily precache a set of files and efficiently manage updates to files."

Step 4 - Author per-recipe runtime tests

Per wb-recipes, each named recipe has a documented default. Pin those defaults with tests.

pageCache() - "respond to a request for an HTML page (through URL navigation) with a network first caching strategy" with a default 3-second network timeout per wb-recipes:

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 });
});

imageCache() - "respond to a request for images with a cache-first caching strategy" with "defaults of 60 maximum images cached for 30 days" per wb-recipes. Pin the 60-entry cap:

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);
});

offlineFallback() - "serve a web page, image, or font if there's a routing error" when users are offline per wb-recipes, defaulting to offline.html:

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();
});

The offline.html default name is per wb-recipes - if a project overrides it via the pageFallback option, the test must match.

googleFontsCache() - uses "stale-while-revalidate for stylesheets and cache-first for font files, with defaults of 30 font files cached for one year" per wb-recipes:

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() - "respond to CSS, JavaScript, and Web Worker requests with a stale-while-revalidate strategy" per wb-recipes:

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() - "load provided URLs into your cache during the service worker's install phase, caching them with the options of the provided strategy" per wb-recipes. 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);
});

Step 5 - Author workbox-window event tests

workbox-window is the page-side companion that "helps with registering a service worker, managing updates, and responding to lifecycle events" per wb-modules. The events emitted are 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 under workbox-window.

Step 6 - Test the cacheable-response plugin gate

Per wb-modules, workbox-cacheable-response "restrict[s] which requests are cached based on a response's status code or headers." A common configuration restricts to 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.ts

The build step is non-optional - workbox-precaching only emits the precache manifest at build time per wb-modules (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.ts

The unit (Vitest) step gates fast; the Playwright recipe step catches the runtime-only regressions.

Parsing results

Workbox runtime caches are observable via three surfaces:

SurfaceWhat it showsHow 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 namespaceFilter by URL pattern to assert what the recipe captured
Playwright page.on('request')Network egress per requestEmpty 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.

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.ts

For 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-patternWhy it failsFix
Assert recipe behavior only on first page loadCache is empty; SW hasn't been installed yetPre-warm by visiting twice (or use await page.waitForLoadState('networkidle'))
Assert caches.keys() includes a fixed namespace namePer wb-recipes, default names can be overridden via cacheNameMatch by suffix substring (name.endsWith('-precache-v2'))
Use Vitest with a JSDOM-mocked caches for recipe behaviorJSDOM does not implement Cache Storage faithfully; ServiceWorkerRegistration is absentUse 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; CDN-served workbox-sw skips itIf using workbox-sw (CDN loader per wb-modules), drop the precache assertion
Test 60-entry cap by checking caches.match returnsExpirationPlugin prunes async; tight await returns stale stateAdd await page.waitForTimeout(500) after the trigger (Step 4 imageCache() test)
Skip the workbox-window event tests entirelyThe page-side "update available" UX is built on these events; breaks silentlyStep 5 covers the five-event vocabulary

Limitations

  • Per-recipe defaults can drift across Workbox majors. The 60-image / 30-day / 1-year numbers cited above are the v7.x defaults per wb-recipes; consult the recipe page at the pinned Workbox version before treating the numbers as test invariants.
  • __WB_MANIFEST is build-tool-injected. Tests against the static dist/sw.js only pass when the bundler ran - local vitest run against src/sw.js will fail to find the array.
  • Cache Storage quota is browser-internal. Workbox's ExpirationPlugin maxEntries is asserted here; the browser's own quota (Step 4 of offline-fallback-test) is a separate ceiling not testable from workbox-* alone.
  • workbox-window's waiting event only fires on update. A test that asserts waiting on first install will fail by design - see wb-modules for the per-event firing conditions.
  • CDN-served workbox-sw sidesteps precaching entirely per wb-modules; this skill's Step 3 assertion does not apply to CDN-loader projects.

References