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