Service Worker Testing: The Complete Developer Guide

Service Worker Testing: The Complete Developer Guide

Service workers are powerful but notoriously difficult to test. They run in a separate thread, control network requests, manage caches, and have a complex lifecycle with install, activate, and fetch events. When a service worker misbehaves, it can silently break your entire app — serving stale content, blocking requests, or failing to update. This guide covers everything you need to test service workers effectively.

Why Service Worker Testing Is Hard

Service workers operate outside the normal browser execution context. Unlike regular JavaScript, they:

  • Run in a background thread with no DOM access
  • Intercept all network requests from controlled clients
  • Persist across page loads and browser sessions
  • Have a non-deterministic update lifecycle
  • Can block an app from ever seeing fresh content if they go wrong

Standard unit test frameworks can't test service workers directly. You need a combination of unit tests for logic, integration tests for the full fetch/cache cycle, and browser automation for end-to-end validation.

Setting Up Your Test Environment

Workbox Test Utilities

If you're using Workbox (Google's service worker library), you get test utilities out of the box:

import { testHelper } from 'workbox-core/_private/test-helpers.mjs';

But for custom service workers, you need to mock the service worker globals.

jest-environment-jsdom Won't Cut It

jsdom doesn't implement Service Worker APIs. Use jest-environment-node combined with manual mocks:

// sw-test-env/globals.js
global.self = global;
global.caches = new MockCacheStorage();
global.clients = new MockClients();
global.fetch = jest.fn();

// sw.test.js
import './sw-test-env/globals';
import '../sw.js';

Using @jest/globals with Service Worker Mocks

A more robust approach uses the jest-serviceworker-mock library:

import makeServiceWorkerEnv from 'service-worker-mock';

describe('Service Worker', () => {
  beforeEach(() => {
    Object.assign(global, makeServiceWorkerEnv());
    jest.resetModules();
  });
  
  it('should cache assets on install', async () => {
    require('../sw.js');
    
    const installEvent = new ExtendableEvent('install');
    await self.trigger('install', installEvent);
    
    const cache = await caches.open('v1');
    const cachedFiles = await cache.keys();
    
    expect(cachedFiles.length).toBeGreaterThan(0);
  });
});

Testing the Install Event

The install event is your chance to pre-cache critical assets. Test that it:

  1. Opens the correct cache name
  2. Caches all required assets
  3. Handles fetch failures gracefully
  4. Calls skipWaiting() if configured
describe('Install Event', () => {
  it('precaches critical assets', async () => {
    const mockFetch = jest.fn().mockResolvedValue(new Response('content'));
    global.fetch = mockFetch;
    
    require('../sw.js');
    await self.trigger('install');
    
    const cache = await caches.open('app-v1');
    const cachedUrls = (await cache.keys()).map(req => req.url);
    
    expect(cachedUrls).toContain('/');
    expect(cachedUrls).toContain('/app.js');
    expect(cachedUrls).toContain('/app.css');
  });
  
  it('fails install if critical asset cannot be fetched', async () => {
    global.fetch = jest.fn().mockRejectedValue(new Error('Network error'));
    
    require('../sw.js');
    
    await expect(self.trigger('install')).rejects.toThrow();
  });
  
  it('calls skipWaiting for immediate activation', async () => {
    const skipWaiting = jest.spyOn(self, 'skipWaiting');
    
    require('../sw.js');
    await self.trigger('install');
    
    expect(skipWaiting).toHaveBeenCalled();
  });
});

Testing the Activate Event

The activate event is where you clean up old caches. Test that outdated caches get deleted:

describe('Activate Event', () => {
  it('deletes caches from previous versions', async () => {
    // Populate old caches
    await caches.open('app-v0').then(cache => 
      cache.put('/old-asset', new Response('old'))
    );
    await caches.open('app-v1').then(cache =>
      cache.put('/new-asset', new Response('new'))
    );
    
    require('../sw.js');
    await self.trigger('activate');
    
    const cacheNames = await caches.keys();
    expect(cacheNames).not.toContain('app-v0');
    expect(cacheNames).toContain('app-v1');
  });
  
  it('calls clients.claim() to take control immediately', async () => {
    const claim = jest.spyOn(self.clients, 'claim');
    
    require('../sw.js');
    await self.trigger('activate');
    
    expect(claim).toHaveBeenCalled();
  });
});

Testing Fetch Interception

This is the heart of service worker testing — verifying your caching strategies work correctly.

Cache-First Strategy

describe('Fetch Handler - Cache First', () => {
  it('returns cached response when available', async () => {
    const cachedResponse = new Response('cached data', {
      headers: { 'Content-Type': 'application/json' }
    });
    
    const cache = await caches.open('api-cache');
    await cache.put('/api/data', cachedResponse.clone());
    
    require('../sw.js');
    
    const fetchEvent = new FetchEvent('fetch', {
      request: new Request('/api/data')
    });
    
    const response = await self.trigger('fetch', fetchEvent);
    const text = await response.text();
    
    expect(text).toBe('cached data');
    expect(global.fetch).not.toHaveBeenCalled();
  });
  
  it('fetches from network when cache miss', async () => {
    global.fetch = jest.fn().mockResolvedValue(
      new Response('fresh data')
    );
    
    require('../sw.js');
    
    const fetchEvent = new FetchEvent('fetch', {
      request: new Request('/api/data')
    });
    
    await self.trigger('fetch', fetchEvent);
    
    expect(global.fetch).toHaveBeenCalledWith('/api/data');
  });
});

Network-First Strategy

describe('Fetch Handler - Network First', () => {
  it('falls back to cache when network fails', async () => {
    global.fetch = jest.fn().mockRejectedValue(new TypeError('Failed to fetch'));
    
    const cache = await caches.open('fallback-cache');
    await cache.put('/page', new Response('<html>Cached page</html>'));
    
    require('../sw.js');
    
    const fetchEvent = new FetchEvent('fetch', {
      request: new Request('/page')
    });
    
    const response = await self.trigger('fetch', fetchEvent);
    expect(response.status).toBe(200);
    
    const html = await response.text();
    expect(html).toContain('Cached page');
  });
});

Testing Cache Versioning and Updates

One of the most critical things to test is whether your service worker properly updates:

describe('Cache Version Management', () => {
  it('uses new cache name after version bump', async () => {
    // Simulate old SW with v1
    process.env.SW_VERSION = 'v1';
    require('../sw.js');
    await self.trigger('install');
    
    jest.resetModules();
    
    // Simulate new SW with v2
    process.env.SW_VERSION = 'v2';
    require('../sw.js');
    await self.trigger('install');
    await self.trigger('activate');
    
    const cacheNames = await caches.keys();
    expect(cacheNames).toContain('app-v2');
    expect(cacheNames).not.toContain('app-v1');
  });
});

End-to-End Testing with Playwright

For realistic service worker testing, you need a real browser. Playwright gives you full control:

import { test, expect } from '@playwright/test';

test('service worker installs and controls page', async ({ page }) => {
  await page.goto('http://localhost:3000');
  
  // Wait for service worker to be ready
  const swState = await page.evaluate(async () => {
    const reg = await navigator.serviceWorker.ready;
    return reg.active?.state;
  });
  
  expect(swState).toBe('activated');
});

test('app works offline after service worker caches assets', async ({ page, context }) => {
  // Load page to trigger caching
  await page.goto('http://localhost:3000');
  
  // Wait for SW to finish caching
  await page.waitForTimeout(1000);
  
  // Go offline
  await context.setOffline(true);
  
  // Page should still load from cache
  await page.goto('http://localhost:3000');
  
  await expect(page.locator('h1')).toBeVisible();
  await expect(page.locator('h1')).not.toContainText('No internet connection');
});

test('service worker serves stale-while-revalidate', async ({ page }) => {
  await page.goto('http://localhost:3000/data');
  
  // Record initial response
  const initialContent = await page.textContent('#content');
  
  // Simulate slow network
  await page.route('**/api/data', async route => {
    await new Promise(resolve => setTimeout(resolve, 2000));
    await route.continue();
  });
  
  // Reload — should see cached content immediately
  const startTime = Date.now();
  await page.reload();
  const loadTime = Date.now() - startTime;
  
  // Served from cache, not waiting for slow network
  expect(loadTime).toBeLessThan(500);
});

Testing with HelpMeTest

For ongoing monitoring of your service worker behavior in production, you can use HelpMeTest to run automated browser tests that verify your PWA works offline, loads from cache, and updates correctly — without any manual intervention.

*** Test Cases ***
Service Worker Offline Test
    [Documentation]    Verify app works offline after first load
    Go To    https://your-pwa.com
    Wait For Service Worker    activated
    Set Network Condition    offline
    Reload Page
    Element Should Be Visible    css:main
    Element Should Not Contain    css:.error    Network error

HelpMeTest runs these tests on a schedule, catching regressions before your users do.

Common Service Worker Testing Pitfalls

1. Not waiting for the service worker to activate. Always use navigator.serviceWorker.ready before testing SW behavior, not just navigator.serviceWorker.register().

2. Testing with browser cache enabled. Service worker caching and browser caching interact. Use Cache-Control: no-store on your test server or use Playwright's bypassCSP to avoid confusion.

3. Forgetting about scope. Service workers only control pages within their scope. A SW registered at /app/sw.js doesn't control /.

4. Not testing the update flow. Most SW bugs happen during updates, not initial installs. Test that old SW gives way to new SW correctly.

5. Race conditions with multiple tabs. A new service worker won't activate while old tabs are open (unless you use skipWaiting). Your tests should account for this.

Testing Background Sync

Background sync lets service workers defer actions until connectivity returns:

describe('Background Sync', () => {
  it('queues failed requests for later sync', async () => {
    global.fetch = jest.fn().mockRejectedValue(new TypeError('Offline'));
    
    require('../sw.js');
    
    const fetchEvent = new FetchEvent('fetch', {
      request: new Request('/api/submit', {
        method: 'POST',
        body: JSON.stringify({ data: 'test' })
      })
    });
    
    await self.trigger('fetch', fetchEvent);
    
    // Verify request was queued in IndexedDB
    const db = await openTestDB();
    const queuedRequests = await db.getAll('sync-queue');
    
    expect(queuedRequests).toHaveLength(1);
    expect(queuedRequests[0].url).toBe('/api/submit');
  });
  
  it('replays queued requests when back online', async () => {
    global.fetch = jest.fn().mockResolvedValue(new Response('OK'));
    
    await seedTestDB([
      { url: '/api/submit', method: 'POST', body: '{"data":"test"}' }
    ]);
    
    require('../sw.js');
    await self.trigger('sync', { tag: 'submit-queue' });
    
    expect(global.fetch).toHaveBeenCalledWith(
      expect.objectContaining({ url: '/api/submit' })
    );
  });
});

CI Integration

Add service worker tests to your CI pipeline:

# .github/workflows/test.yml
- name: Run service worker unit tests
  run: npm run test:sw

- name: Run PWA E2E tests
  run: npx playwright test --project=chromium tests/pwa/

- name: Lighthouse PWA audit
  uses: treosh/lighthouse-ci-action@v11
  with:
    urls: 'http://localhost:3000'
    configPath: './lighthouserc.json'

Conclusion

Service worker testing requires a layered approach: unit tests for business logic, integration tests for caching strategies, and browser automation for realistic behavior validation. The combination of service-worker-mock for unit tests and Playwright for E2E gives you comprehensive coverage.

The most important thing to test is your update path — service workers that update incorrectly are the source of endless "but I already fixed that" bug reports. Test your versioning, your cache deletion, and your skipWaiting behavior explicitly. Your future self will thank you.

Read more

Start now free