19 KiB
Executable file
Service Workers and PWA Testing
When to use: When your application is a Progressive Web App (PWA) or uses service workers for offline support, caching, push notifications, or background sync. Prerequisites: core/configuration.md, core/assertions-and-waiting.md
Quick Reference
// Service workers require a persistent context — use launchPersistentContext
const context = await chromium.launchPersistentContext(userDataDir, {
baseURL: 'https://localhost:3000',
});
// Enable/disable offline mode
await context.setOffline(true); // Go offline
await context.setOffline(false); // Come back online
// Wait for service worker to register
const sw = await context.waitForEvent('serviceworker');
console.log('SW URL:', sw.url());
Patterns
Service Worker Registration
Use when: You need to verify that your service worker registers successfully, activates, and controls the page. Avoid when: Your app does not use service workers.
Service workers require a persistent browser context because they are tied to a profile. The default page fixture uses a temporary context that does not persist service worker registrations across navigations reliably.
TypeScript
import { test as base, expect, chromium, BrowserContext } from '@playwright/test';
import path from 'path';
import fs from 'fs';
// Create a fixture with a persistent context for SW testing
const test = base.extend<{ swContext: BrowserContext }>({
swContext: async ({}, use) => {
const userDataDir = path.join(__dirname, '.tmp-profile');
const context = await chromium.launchPersistentContext(userDataDir, {
baseURL: 'https://localhost:3000',
});
await use(context);
await context.close();
fs.rmSync(userDataDir, { recursive: true, force: true });
},
});
test('service worker registers and activates', async ({ swContext }) => {
const page = await swContext.newPage();
// Listen for the service worker event
const swPromise = swContext.waitForEvent('serviceworker');
await page.goto('/');
const sw = await swPromise;
expect(sw.url()).toContain('service-worker.js');
// Verify the SW is controlling the page
const isControlled = await page.evaluate(() => {
return navigator.serviceWorker.controller !== null;
});
expect(isControlled).toBe(true);
});
JavaScript
const { test: base, expect, chromium } = require('@playwright/test');
const path = require('path');
const fs = require('fs');
const test = base.extend({
swContext: async ({}, use) => {
const userDataDir = path.join(__dirname, '.tmp-profile');
const context = await chromium.launchPersistentContext(userDataDir, {
baseURL: 'https://localhost:3000',
});
await use(context);
await context.close();
fs.rmSync(userDataDir, { recursive: true, force: true });
},
});
test('service worker registers and activates', async ({ swContext }) => {
const page = await swContext.newPage();
const swPromise = swContext.waitForEvent('serviceworker');
await page.goto('/');
const sw = await swPromise;
expect(sw.url()).toContain('service-worker.js');
const isControlled = await page.evaluate(() => {
return navigator.serviceWorker.controller !== null;
});
expect(isControlled).toBe(true);
});
Offline Mode Testing
Use when: Your PWA should work offline -- serving cached pages, showing offline indicators, queuing data for sync. Avoid when: Your app has no offline support.
TypeScript
import { test, expect } from '@playwright/test';
test('app serves cached page when offline', async ({ swContext }) => {
const page = await swContext.newPage();
// First visit — caches the page and assets
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
// Wait for the service worker to finish caching
await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready;
// Wait for the SW to be in the 'activated' state
return registration.active?.state === 'activated';
});
// Go offline
await swContext.setOffline(true);
// Reload — should serve from cache
await page.reload();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
// Verify offline indicator appears
await expect(page.getByText('You are offline')).toBeVisible();
// Come back online
await swContext.setOffline(false);
await expect(page.getByText('You are offline')).toBeHidden();
});
test('form submission queues when offline', async ({ swContext }) => {
const page = await swContext.newPage();
await page.goto('/feedback');
// Ensure SW is active
await page.evaluate(() => navigator.serviceWorker.ready);
// Go offline
await swContext.setOffline(true);
// Submit a form while offline
await page.getByLabel('Feedback').fill('Great product!');
await page.getByRole('button', { name: 'Submit' }).click();
// App should show a "queued" message, not an error
await expect(page.getByText('Saved offline. Will sync when connected.')).toBeVisible();
// Come back online — the queued data should sync
await swContext.setOffline(false);
await expect(page.getByText('Feedback submitted successfully')).toBeVisible({ timeout: 10000 });
});
JavaScript
const { test, expect } = require('@playwright/test');
test('app serves cached page when offline', async ({ swContext }) => {
const page = await swContext.newPage();
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await page.evaluate(async () => {
const registration = await navigator.serviceWorker.ready;
return registration.active?.state === 'activated';
});
await swContext.setOffline(true);
await page.reload();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByText('You are offline')).toBeVisible();
await swContext.setOffline(false);
await expect(page.getByText('You are offline')).toBeHidden();
});
Cache Verification
Use when: You need to confirm that specific resources are cached by the service worker. Avoid when: You trust the framework's caching strategy and only need to verify the offline UX (test offline mode instead).
TypeScript
import { test, expect } from '@playwright/test';
test('critical assets are cached by the service worker', async ({ swContext }) => {
const page = await swContext.newPage();
await page.goto('/');
// Wait for SW to be ready
await page.evaluate(() => navigator.serviceWorker.ready);
// Check the cache contents
const cachedUrls = await page.evaluate(async () => {
const cacheNames = await caches.keys();
const allUrls: string[] = [];
for (const name of cacheNames) {
const cache = await caches.open(name);
const keys = await cache.keys();
allUrls.push(...keys.map((req) => new URL(req.url).pathname));
}
return allUrls;
});
// Verify critical resources are cached
expect(cachedUrls).toContain('/');
expect(cachedUrls).toContain('/offline.html');
expect(cachedUrls.some((url) => url.endsWith('.js'))).toBe(true);
expect(cachedUrls.some((url) => url.endsWith('.css'))).toBe(true);
});
test('cache is invalidated after service worker update', async ({ swContext }) => {
const page = await swContext.newPage();
await page.goto('/');
// Get initial cache version
const initialCaches = await page.evaluate(() => caches.keys());
// Simulate a new SW version by clearing and reloading
await page.evaluate(async () => {
const registration = await navigator.serviceWorker.getRegistration();
await registration?.update();
});
// After update, old caches should be cleaned up
await page.reload();
await page.evaluate(() => navigator.serviceWorker.ready);
const updatedCaches = await page.evaluate(() => caches.keys());
// The app should manage cache names with versioning
expect(updatedCaches.length).toBeGreaterThanOrEqual(1);
});
JavaScript
const { test, expect } = require('@playwright/test');
test('critical assets are cached by the service worker', async ({ swContext }) => {
const page = await swContext.newPage();
await page.goto('/');
await page.evaluate(() => navigator.serviceWorker.ready);
const cachedUrls = await page.evaluate(async () => {
const cacheNames = await caches.keys();
const allUrls = [];
for (const name of cacheNames) {
const cache = await caches.open(name);
const keys = await cache.keys();
allUrls.push(...keys.map((req) => new URL(req.url).pathname));
}
return allUrls;
});
expect(cachedUrls).toContain('/');
expect(cachedUrls).toContain('/offline.html');
});
PWA Install Prompt
Use when: You need to test the "Add to Home Screen" / PWA install flow.
Avoid when: Your app is not a PWA or does not handle the beforeinstallprompt event.
The beforeinstallprompt event cannot be synthetically triggered by Playwright. Instead, verify that your app captures and handles the event correctly.
TypeScript
import { test, expect } from '@playwright/test';
test('app captures install prompt and shows install button', async ({ swContext }) => {
const page = await swContext.newPage();
// Mock the beforeinstallprompt event
await page.addInitScript(() => {
let deferredPrompt: Event | null = null;
// Simulate the browser firing beforeinstallprompt
window.addEventListener('load', () => {
const event = new Event('beforeinstallprompt');
(event as any).preventDefault = () => {};
(event as any).prompt = () => Promise.resolve();
(event as any).userChoice = Promise.resolve({ outcome: 'accepted' });
window.dispatchEvent(event);
});
});
await page.goto('/');
// Your app should show an install button when it captures the prompt
await expect(page.getByRole('button', { name: 'Install app' })).toBeVisible();
});
test('web app manifest is valid', async ({ swContext }) => {
const page = await swContext.newPage();
await page.goto('/');
// Verify manifest link exists
const manifestUrl = await page.evaluate(() => {
const link = document.querySelector('link[rel="manifest"]') as HTMLLinkElement;
return link?.href;
});
expect(manifestUrl).toBeTruthy();
// Fetch and validate the manifest
const response = await page.request.get(manifestUrl!);
expect(response.ok()).toBe(true);
const manifest = await response.json();
expect(manifest.name).toBeTruthy();
expect(manifest.icons).toHaveLength(expect.any(Number));
expect(manifest.start_url).toBeTruthy();
expect(manifest.display).toBe('standalone');
});
JavaScript
const { test, expect } = require('@playwright/test');
test('app captures install prompt and shows install button', async ({ swContext }) => {
const page = await swContext.newPage();
await page.addInitScript(() => {
window.addEventListener('load', () => {
const event = new Event('beforeinstallprompt');
event.preventDefault = () => {};
event.prompt = () => Promise.resolve();
event.userChoice = Promise.resolve({ outcome: 'accepted' });
window.dispatchEvent(event);
});
});
await page.goto('/');
await expect(page.getByRole('button', { name: 'Install app' })).toBeVisible();
});
Push Notification Testing
Use when: Your PWA uses the Push API to receive push notifications from a server. Avoid when: Notifications are handled entirely client-side without the Push API.
TypeScript
import { test, expect } from '@playwright/test';
test('push notification triggers UI update', async ({ swContext }) => {
const page = await swContext.newPage();
await page.goto('/');
// Wait for SW to be ready
const swURL = await page.evaluate(async () => {
const reg = await navigator.serviceWorker.ready;
return reg.active?.scriptURL;
});
expect(swURL).toBeTruthy();
// Get the service worker and evaluate inside it
const sw = swContext.serviceWorkers()[0];
// Simulate a push event inside the service worker
await sw.evaluate(async () => {
const event = new PushEvent('push', {
data: new TextEncoder().encode(JSON.stringify({
title: 'New Message',
body: 'You have a new message from Alice',
url: '/messages',
})),
});
// @ts-ignore — dispatchEvent works in SW context
self.dispatchEvent(event);
});
// Verify the app reacts (e.g., shows a badge or notification banner)
await expect(page.getByTestId('notification-badge')).toHaveText('1');
});
JavaScript
const { test, expect } = require('@playwright/test');
test('push notification triggers UI update', async ({ swContext }) => {
const page = await swContext.newPage();
await page.goto('/');
const swURL = await page.evaluate(async () => {
const reg = await navigator.serviceWorker.ready;
return reg.active?.scriptURL;
});
expect(swURL).toBeTruthy();
const sw = swContext.serviceWorkers()[0];
await sw.evaluate(async () => {
const event = new PushEvent('push', {
data: new TextEncoder().encode(JSON.stringify({
title: 'New Message',
body: 'You have a new message from Alice',
url: '/messages',
})),
});
self.dispatchEvent(event);
});
await expect(page.getByTestId('notification-badge')).toHaveText('1');
});
Background Sync Testing
Use when: Your app uses the Background Sync API to defer network requests until the user has connectivity. Avoid when: Your app does not use background sync.
TypeScript
import { test, expect } from '@playwright/test';
test('background sync retries failed requests when online', async ({ swContext }) => {
const page = await swContext.newPage();
await page.goto('/tasks');
// Ensure SW is active
await page.evaluate(() => navigator.serviceWorker.ready);
// Go offline
await swContext.setOffline(true);
// Create a task (will fail to sync)
await page.getByLabel('New task').fill('Buy groceries');
await page.getByRole('button', { name: 'Add' }).click();
// Task shows as "pending sync"
await expect(page.getByText('Buy groceries')).toBeVisible();
await expect(page.getByTestId('sync-status')).toHaveText('Pending');
// Come back online — background sync should fire
await swContext.setOffline(false);
// Wait for sync to complete
await expect(page.getByTestId('sync-status')).toHaveText('Synced', { timeout: 15000 });
});
JavaScript
const { test, expect } = require('@playwright/test');
test('background sync retries failed requests when online', async ({ swContext }) => {
const page = await swContext.newPage();
await page.goto('/tasks');
await page.evaluate(() => navigator.serviceWorker.ready);
await swContext.setOffline(true);
await page.getByLabel('New task').fill('Buy groceries');
await page.getByRole('button', { name: 'Add' }).click();
await expect(page.getByText('Buy groceries')).toBeVisible();
await expect(page.getByTestId('sync-status')).toHaveText('Pending');
await swContext.setOffline(false);
await expect(page.getByTestId('sync-status')).toHaveText('Synced', { timeout: 15000 });
});
Decision Guide
| Scenario | Approach | Why |
|---|---|---|
| Verify SW registers | context.waitForEvent('serviceworker') |
Confirms the SW file was fetched and registered |
| Test offline page serving | context.setOffline(true) + reload |
Simulates network loss at the browser level |
| Verify specific assets are cached | page.evaluate(() => caches.keys()) |
Directly queries the Cache API |
| Test install prompt | addInitScript to mock beforeinstallprompt |
Browser does not fire this event in automation |
| Validate web manifest | page.request.get(manifestUrl) |
Fetch and parse the JSON manifest |
| Test push notifications | sw.evaluate() to dispatch PushEvent |
Simulates a push message inside the SW |
| Test background sync | Go offline, perform action, come back online | Verifies the sync queue processes correctly |
| Test SW update flow | registration.update() via page.evaluate |
Triggers a manual SW update check |
Anti-Patterns
| Don't Do This | Problem | Do This Instead |
|---|---|---|
Test service workers with the default page fixture |
Default context is temporary; SWs may not persist | Use launchPersistentContext for reliable SW testing |
Skip navigator.serviceWorker.ready before going offline |
SW may not have finished caching | Always await page.evaluate(() => navigator.serviceWorker.ready) first |
Use page.route() to simulate offline |
Only intercepts Playwright-visible requests; SW fetch events are not intercepted | Use context.setOffline(true) for true network-level offline |
| Test the SW JavaScript file directly with unit tests only | Misses integration with the page and caching behavior | Combine unit tests with Playwright end-to-end SW tests |
| Assume caches are clean at test start | Previous test runs may leave stale caches | Clear caches in fixture setup or use a fresh userDataDir |
waitForTimeout after setOffline(false) |
Background sync timing is unpredictable | Use expect(...).toHaveText('Synced', { timeout: 15000 }) |
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
context.waitForEvent('serviceworker') never resolves |
SW already registered before listener attached | Register listener before page.goto(), or check context.serviceWorkers() |
| SW does not activate | Old SW is still controlling the page | Use skipWaiting() in the SW or clear the userDataDir |
| Offline test still loads fresh content | SW cache was not populated before going offline | Wait for navigator.serviceWorker.ready and verify cache contents first |
context.setOffline(true) has no effect |
Using a non-persistent context or the wrong context | Ensure offline is set on the same context that owns the page |
| Push event does nothing | SW is not the active controller | Ensure sw.evaluate() targets the correct service worker from context.serviceWorkers() |
| Tests interfere with each other | Shared userDataDir between tests |
Use a unique temp directory per test, cleaned in fixture teardown |
| Service worker tests only work in Chromium | Firefox and WebKit have limited SW support in Playwright | Run SW tests in Chromium only via test.skip or project config |
Related
- core/browser-apis.md -- IndexedDB and localStorage used alongside service workers
- core/configuration.md -- project-level config for PWA testing
- core/fixtures-and-hooks.md -- wrapping persistent context in fixtures
- core/websockets-and-realtime.md -- real-time features that interact with offline/online state