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:
- Opens the correct cache name
- Caches all required assets
- Handles fetch failures gracefully
- 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 errorHelpMeTest 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.