# 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](configuration.md), [core/assertions-and-waiting.md](assertions-and-waiting.md) ## Quick Reference ```typescript // 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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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](browser-apis.md) -- IndexedDB and localStorage used alongside service workers - [core/configuration.md](configuration.md) -- project-level config for PWA testing - [core/fixtures-and-hooks.md](fixtures-and-hooks.md) -- wrapping persistent context in fixtures - [core/websockets-and-realtime.md](websockets-and-realtime.md) -- real-time features that interact with offline/online state