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:integrationContinuous 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:
- Unit tests (
service-worker-mock+ Jest): test fetch handlers, install handlers, strategy logic in isolation — fast, no browser - Integration tests (
sw-test-env): import and run your actual SW file in Node.js — catches issues in your SW code itself - 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.