Testing Service Workers with Workbox, jest-service-worker, and sw-test-env

Testing Service Workers with Workbox, jest-service-worker, and sw-test-env

Test Workbox service workers at three levels: unit test routing strategies with workbox-strategies in a mocked environment, integration test fetch behavior using sw-test-env which runs your service worker in Node.js, and end-to-end test cache behavior with Playwright. The makeServiceWorkerEnv() function from jest-service-worker (now service-worker-mock) gives you a DOM-less environment where self, caches, and fetch are all available.

Key Takeaways

Service workers are just JavaScript — unit test the logic. Extract route handlers, cache strategies, and business logic into plain functions. Test them with Jest without needing a browser.

Use service-worker-mock to test event handlers. The service-worker-mock package provides self, caches, fetch, clients, and all other service worker globals in a Jest environment.

sw-test-env runs your SW in Node.js. Unlike mocks, sw-test-env actually imports and runs your service worker file in Node.js with a realistic environment. Use it for integration tests of the full service worker.

Test cache state explicitly. After simulating a fetch event, open the cache and verify the response was stored. Don't just check that the response came back — check that caching did its job.

Workbox strategies are composable and testable. CacheFirst, NetworkFirst, StaleWhileRevalidate are all classes you can instantiate and call .handle() on directly in tests.

Service workers are one of the hardest things to test in web development. They run in a separate thread, intercept network requests, and have a complex lifecycle. But with the right tools — service-worker-mock, sw-test-env, and Workbox's testing utilities — you can test service workers effectively at every level.

Service Worker Testing Levels

Level Tool What You Test
Unit service-worker-mock + Jest Route handlers, strategies, cache logic
Integration sw-test-env Full SW file in Node.js environment
E2E Playwright + context.setOffline Real browser, real cache, real network

Start with unit tests for speed, use integration tests for confidence, and e2e tests for final verification.

Setting Up service-worker-mock

service-worker-mock (formerly jest-service-worker) provides all service worker globals for Jest:

npm install --save-dev service-worker-mock jest
// jest.config.js
module.exports = {
  testEnvironment: 'node',
  setupFiles: ['./test/setup-sw-env.js'],
};

// test/setup-sw-env.js
const makeServiceWorkerEnv = require('service-worker-mock');

Object.assign(global, makeServiceWorkerEnv(), {
  // Override fetch for your tests
  fetch: jest.fn(),
});

// Workaround: ensure console is available
global.console = console;

With this setup, self, caches, fetch, clients, Request, Response, and FetchEvent are all available in your test files.

Unit Testing Service Worker Event Handlers

// sw.js (your service worker)
const CACHE_NAME = 'my-app-v1';
const STATIC_ASSETS = ['/index.html', '/styles.css', '/app.js'];

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME).then(cache => cache.addAll(STATIC_ASSETS))
  );
});

self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request).then(cached => {
      return cached ?? fetch(event.request).then(response => {
        const clone = response.clone();
        caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
        return response;
      });
    })
  );
});
// test/sw.unit.test.js
require('../sw'); // Runs the service worker code in the mock environment

describe('Service Worker Install', () => {
  beforeEach(() => {
    self.caches.clear();
    jest.clearAllMocks();
  });

  test('caches static assets on install', async () => {
    // Simulate install event
    const installEvent = new self.ExtendableEvent('install');
    await self.trigger('install');

    // Verify assets were cached
    const cache = await caches.open('my-app-v1');
    const cached = await cache.match('/index.html');
    expect(cached).toBeDefined();
  });
});

describe('Service Worker Fetch', () => {
  test('returns cached response when available', async () => {
    // Pre-populate cache
    const cache = await caches.open('my-app-v1');
    const cachedResponse = new Response('<html>cached</html>', {
      headers: { 'Content-Type': 'text/html' },
    });
    await cache.put('/index.html', cachedResponse);

    // Trigger fetch event
    const response = await self.trigger('fetch', new Request('/index.html'));
    const text = await response.text();

    expect(text).toBe('<html>cached</html>');
    expect(fetch).not.toHaveBeenCalled(); // Should not hit network
  });

  test('fetches and caches when not in cache', async () => {
    const networkResponse = new Response('<html>fresh</html>', {
      headers: { 'Content-Type': 'text/html' },
    });
    fetch.mockResolvedValueOnce(networkResponse.clone());

    const response = await self.trigger('fetch', new Request('/new-page.html'));
    const text = await response.text();

    expect(text).toBe('<html>fresh</html>');

    // Verify it was cached
    const cache = await caches.open('my-app-v1');
    const cached = await cache.match('/new-page.html');
    expect(cached).toBeDefined();
  });
});

Testing Workbox Strategies

Workbox's strategies are testable classes. You can instantiate them and call .handle() directly:

// test/workboxStrategies.test.js
const { CacheFirst, NetworkFirst, StaleWhileRevalidate } = require('workbox-strategies');
const { registerRoute } = require('workbox-routing');

describe('CacheFirst Strategy', () => {
  let strategy;

  beforeEach(() => {
    self.caches.clear();
    strategy = new CacheFirst({ cacheName: 'static-cache' });
    fetch.mockReset();
  });

  test('serves from cache on hit', async () => {
    // Pre-populate cache
    const cache = await caches.open('static-cache');
    await cache.put('/logo.png', new Response('image-data'));

    const request = new Request('/logo.png');
    const event = new FetchEvent('fetch', { request });
    const response = await strategy.handle({ request, event });

    expect(fetch).not.toHaveBeenCalled();
    const text = await response.text();
    expect(text).toBe('image-data');
  });

  test('fetches and caches on miss', async () => {
    fetch.mockResolvedValueOnce(
      new Response('fresh-image', { status: 200 })
    );

    const request = new Request('/new-logo.png');
    const event = new FetchEvent('fetch', { request });
    const response = await strategy.handle({ request, event });

    expect(fetch).toHaveBeenCalledWith(expect.objectContaining({ url: 'http://localhost/new-logo.png' }));

    // Verify cached
    const cache = await caches.open('static-cache');
    const cached = await cache.match('/new-logo.png');
    expect(cached).toBeDefined();
  });
});

describe('NetworkFirst Strategy', () => {
  let strategy;

  beforeEach(() => {
    self.caches.clear();
    strategy = new NetworkFirst({ cacheName: 'api-cache' });
    fetch.mockReset();
  });

  test('prefers network over cache', async () => {
    // Pre-populate cache with stale data
    const cache = await caches.open('api-cache');
    await cache.put('/api/data', new Response(JSON.stringify({ stale: true })));

    // Network returns fresh data
    fetch.mockResolvedValueOnce(
      new Response(JSON.stringify({ stale: false }), {
        headers: { 'Content-Type': 'application/json' },
      })
    );

    const request = new Request('/api/data');
    const event = new FetchEvent('fetch', { request });
    const response = await strategy.handle({ request, event });

    const data = await response.json();
    expect(data.stale).toBe(false);
    expect(fetch).toHaveBeenCalled();
  });

  test('falls back to cache when network fails', async () => {
    // Pre-populate cache
    const cache = await caches.open('api-cache');
    await cache.put('/api/data', new Response(JSON.stringify({ cached: true })));

    // Network fails
    fetch.mockRejectedValueOnce(new TypeError('Failed to fetch'));

    const request = new Request('/api/data');
    const event = new FetchEvent('fetch', { request });
    const response = await strategy.handle({ request, event });

    const data = await response.json();
    expect(data.cached).toBe(true);
  });
});

describe('StaleWhileRevalidate Strategy', () => {
  let strategy;

  beforeEach(() => {
    self.caches.clear();
    strategy = new StaleWhileRevalidate({ cacheName: 'swr-cache' });
    fetch.mockReset();
  });

  test('returns stale cache immediately and revalidates', async () => {
    // Pre-populate with stale response
    const cache = await caches.open('swr-cache');
    await cache.put('/api/news', new Response(JSON.stringify({ articles: ['old'] })));

    const freshResponse = new Response(JSON.stringify({ articles: ['new'] }), {
      headers: { 'Content-Type': 'application/json' },
    });
    fetch.mockResolvedValueOnce(freshResponse);

    const request = new Request('/api/news');
    const event = new FetchEvent('fetch', { request });

    const start = Date.now();
    const response = await strategy.handle({ request, event });
    const elapsed = Date.now() - start;

    // Should return immediately (from cache)
    expect(elapsed).toBeLessThan(50);
    const data = await response.json();
    expect(data.articles).toContain('old');

    // Network should have been called (for revalidation)
    expect(fetch).toHaveBeenCalled();
  });
});

Integration Testing with sw-test-env

sw-test-env lets you import and run your actual service worker file in Node.js, providing a more realistic test environment than mocks:

npm install --save-dev sw-test-env
// test/sw.integration.test.js
const { makeServiceWorkerEnv, fromAssets } = require('sw-test-env');

describe('Service Worker Integration', () => {
  let swEnvironment;

  beforeEach(async () => {
    // Load and run the actual service worker
    swEnvironment = await makeServiceWorkerEnv();
    await swEnvironment.importScripts('/dist/sw.js');
    await swEnvironment.trigger('install');
    await swEnvironment.trigger('activate');
  });

  afterEach(() => {
    swEnvironment.restore();
  });

  test('caches app shell on install', async () => {
    const { caches } = swEnvironment.self;
    const cache = await caches.open('my-app-v1');

    const indexCached = await cache.match('/index.html');
    expect(indexCached).not.toBeNull();

    const cssCached = await cache.match('/styles.css');
    expect(cssCached).not.toBeNull();
  });

  test('serves offline fallback for uncached navigation', async () => {
    const { self } = swEnvironment;

    // Simulate a fetch for an uncached URL
    const request = new self.Request('/unknown-page', { mode: 'navigate' });
    const response = await swEnvironment.trigger('fetch', request);

    expect(response.status).toBe(200);
    const text = await response.text();
    expect(text).toContain('offline');
  });

  test('push event triggers notification', async () => {
    const { self } = swEnvironment;

    // Spy on showNotification
    const showNotification = jest.spyOn(self.registration, 'showNotification');

    // Trigger push event
    const pushData = { title: 'New message', body: 'You have mail' };
    await swEnvironment.trigger('push', {
      data: {
        json: () => pushData,
        text: () => JSON.stringify(pushData),
      },
    });

    expect(showNotification).toHaveBeenCalledWith('New message', expect.objectContaining({
      body: 'You have mail',
    }));
  });
});

Testing Cache Expiration

Workbox supports TTL-based cache expiration via ExpirationPlugin. Test that stale entries are purged:

// test/cacheExpiration.test.js
const { CacheFirst } = require('workbox-strategies');
const { ExpirationPlugin } = require('workbox-expiration');

describe('Cache Expiration', () => {
  test('expired entries are not served', async () => {
    const strategy = new CacheFirst({
      cacheName: 'expiring-cache',
      plugins: [
        new ExpirationPlugin({
          maxAgeSeconds: 60, // 1 minute
        }),
      ],
    });

    // Add a response with an old timestamp
    const cache = await caches.open('expiring-cache');
    const oldResponse = new Response('old data', {
      headers: {
        'Date': new Date(Date.now() - 2 * 60 * 1000).toUTCString(), // 2 minutes ago
      },
    });
    await cache.put('/old-asset', oldResponse);

    // Simulate network returning fresh response
    fetch.mockResolvedValueOnce(new Response('fresh data'));

    const response = await strategy.handle({
      request: new Request('/old-asset'),
      event: new FetchEvent('fetch', { request: new Request('/old-asset') }),
    });

    const text = await response.text();
    expect(text).toBe('fresh data'); // Should have fetched fresh, not served expired
  });
});

Testing Background Sync

// test/backgroundSync.test.js
require('../sw');

describe('Background Sync', () => {
  test('queues failed requests for retry', async () => {
    fetch.mockRejectedValueOnce(new TypeError('Failed to fetch'));

    const request = new Request('/api/submit', {
      method: 'POST',
      body: JSON.stringify({ data: 'important' }),
    });

    await self.trigger('fetch', request);

    // Verify it was queued in background sync
    const syncRegistrations = await self.registration.sync.getTags();
    expect(syncRegistrations).toContain('submit-queue');
  });

  test('replays queued requests on sync event', async () => {
    // Pre-queue a request
    const cache = await caches.open('bg-sync-queue');
    await cache.put('queued-request-1', new Response(JSON.stringify({
      url: '/api/submit',
      method: 'POST',
      body: JSON.stringify({ data: 'queued' }),
    })));

    fetch.mockResolvedValueOnce(new Response(JSON.stringify({ success: true })));

    await self.trigger('sync', { tag: 'submit-queue' });

    expect(fetch).toHaveBeenCalledWith(
      expect.objectContaining({ url: 'http://localhost/api/submit' }),
      expect.anything()
    );
  });
});

Running Service Worker Tests in CI

# .github/workflows/sw-tests.yml
name: Service Worker Tests

on: [push, pull_request]

jobs:
  sw-unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm run test:sw:unit
        # Runs: jest --testPathPattern=test/sw

  sw-integration:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm run build  # Build SW before integration tests
      - run: npm run test:sw:integration

Continuous Monitoring

Service worker bugs are particularly insidious — a caching bug can leave users stuck with stale content for days because the service worker intercepts all requests. HelpMeTest runs your service worker test scenarios every 5 minutes and alerts you when offline mode breaks or stale responses appear in places they shouldn't. No infrastructure to manage at $100/month flat.

Summary

Service worker testing in three layers:

  1. Unit tests (service-worker-mock + Jest): test fetch handlers, install handlers, strategy logic in isolation — fast, no browser
  2. Integration tests (sw-test-env): import and run your actual SW file in Node.js — catches issues in your SW code itself
  3. E2E tests (Playwright + context.setOffline): test real cache behavior in a real browser

Workbox strategies (CacheFirst, NetworkFirst, StaleWhileRevalidate) are classes you can instantiate and test directly. This makes it possible to test caching behavior without a full browser setup, dramatically reducing test execution time.

Read more