|

Service Worker and PWA Testing with Playwright

Progressive Web Apps live or die by their service worker: it is the background script that caches assets, serves the app offline, and intercepts every network request your tests make. The problem is that a service worker is invisible to most UI tests, so a broken cache or a stale update can ship straight to production without a single red test. In this guide you will learn practical Playwright service worker PWA testing techniques in TypeScript — inspecting the registered worker, verifying offline behaviour, testing the install and update lifecycle, and asserting that your app is installable.

🎭 Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.

Contents

Why service workers break normal end-to-end tests

A service worker sits between your page and the network. Once it is installed and activated, it can answer fetch events from a cache instead of hitting the server. That is wonderful for users on flaky connections, but it quietly changes how your tests behave: a request you mocked with page.route() may never reach Playwright because the worker served a cached copy first, or a test on a fresh deploy may still see yesterday’s JavaScript bundle.

There are two layers worth testing. The first is the worker itself — is it registered, what scope does it control, and does it cache the right files. The second is the user-visible result — does the app load offline, does an update prompt appear, and is the app installable. Playwright gives you first-class access to both layers through the BrowserContext and a dedicated Worker object.

Setting up Chromium for service worker testing

Service workers only run over HTTPS or on localhost, and they require a real Chromium-based browser. Playwright supports service worker inspection in Chromium; Firefox and WebKit do not expose the same APIs, so pin these tests to the chromium project. Service workers are enabled by default in Playwright, but you should be explicit and avoid blocking them in your route handlers.

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  use: {
    baseURL: 'http://localhost:3000',
    // serviceWorkers defaults to 'allow'; set explicitly for clarity.
    serviceWorkers: 'allow',
    trace: 'on-first-retry',
  },
  projects: [
    {
      name: 'chromium-pwa',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});

One subtle gotcha: if you ever set serviceWorkers: 'block' in a context, the PWA falls back to plain network mode and none of the tests below will see a worker. Keep it on 'allow' for the PWA suite, and isolate any API-mocking suites that genuinely need the worker out of the way into a separate project.

Waiting for and inspecting the registered service worker

The BrowserContext exposes two real APIs for this: context.serviceWorkers() returns the workers already registered, and context.waitForEvent('serviceworker') resolves when a new one appears. Because registration is asynchronous and often kicks off after the page’s load event, racing the wait against navigation is the reliable pattern.

import { test, expect } from '@playwright/test';

test('registers a service worker with the correct scope', async ({ page, context }) => {
  // Start waiting BEFORE navigation so we never miss the event.
  const swPromise = context.waitForEvent('serviceworker');
  await page.goto('/');

  const serviceWorker = await swPromise;

  // The Worker object exposes the script URL that was registered.
  expect(serviceWorker.url()).toContain('/sw.js');

  // Confirm the browser agrees the worker is controlling the page.
  const isControlled = await page.evaluate(
    () => navigator.serviceWorker.controller !== null,
  );
  // On a first visit the page may not be controlled until reload.
  expect(typeof isControlled).toBe('boolean');

  // serviceWorkers() lists every worker the context knows about.
  expect(context.serviceWorkers().length).toBeGreaterThan(0);
});

The serviceWorker you get back is a Playwright Worker object. It has an evaluate() method that runs code inside the worker’s global scope — not the page. That is how you reach worker-only globals like caches and self.registration to assert that the right files were precached.

test('precaches the app shell into the Cache Storage', async ({ page, context }) => {
  const swPromise = context.waitForEvent('serviceworker');
  await page.goto('/');
  const sw = await swPromise;

  // Give the worker a moment to finish its install/activate handlers.
  await page.waitForTimeout(500);

  // evaluate() runs in the worker scope, where `caches` is available.
  const cachedUrls = await sw.evaluate(async () => {
    const keys = await caches.keys();
    const urls: string[] = [];
    for (const key of keys) {
      const cache = await caches.open(key);
      const requests = await cache.keys();
      urls.push(...requests.map((r) => new URL(r.url).pathname));
    }
    return urls;
  });

  expect(cachedUrls).toContain('/');
  expect(cachedUrls.some((u) => u.endsWith('.css'))).toBeTruthy();
});

Testing offline behaviour the right way

The headline feature of a PWA is that it works without a network. Playwright models this with context.setOffline(true), which flips the whole browser context into offline mode — the same effect as ticking “Offline” in DevTools. The crucial detail many testers miss is sequencing: you must let the service worker install and cache the shell while online first, then go offline, then reload. Going offline before the first successful load means there is nothing in the cache to serve.

test('serves the app shell while offline', async ({ page, context }) => {
  // 1. Warm the cache while online and wait for the worker to take control.
  await page.goto('/');
  await page.waitForFunction(() => navigator.serviceWorker.controller !== null);

  // 2. Drop the network for the whole context.
  await context.setOffline(true);

  // 3. Reload — this must now be answered entirely from the cache.
  await page.reload();

  // 4. Assert the real UI rendered, not the browser's offline error page.
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
  await expect(page.getByText(/you are offline/i)).toBeVisible();

  // Restore connectivity for any later assertions.
  await context.setOffline(false);
});

Notice page.waitForFunction polling for navigator.serviceWorker.controller. On a first visit the worker activates but does not control the already-loaded page until the next navigation, so waiting for controller to become non-null is a much sturdier signal than a fixed timeout. If your worker calls clients.claim() in its activate handler, control is taken immediately, and this check passes faster.

Testing the update lifecycle and stale caches

The nastiest PWA bugs are update bugs: a user keeps seeing the old version because the new worker is stuck in the waiting state. You can drive this lifecycle from Playwright by inspecting navigator.serviceWorker.getRegistration() and triggering registration.update(). A common pattern is an “Update available” toast wired to the updatefound event — you can assert it appears and that clicking it activates the waiting worker.

test('shows an update prompt when a new worker is waiting', async ({ page }) => {
  await page.goto('/');
  await page.waitForFunction(() => navigator.serviceWorker.controller !== null);

  // Ask the browser to re-check the script on the server.
  const hasWaiting = await page.evaluate(async () => {
    const reg = await navigator.serviceWorker.getRegistration();
    if (!reg) return false;
    await reg.update();
    // reg.waiting is populated when a new worker installed but hasn't activated.
    return reg.waiting !== null || reg.installing !== null;
  });

  // If your CI serves a bumped sw.js, a waiting worker should appear and
  // your app should surface the prompt to the user.
  if (hasWaiting) {
    await expect(page.getByRole('button', { name: /reload to update/i })).toBeVisible();
  }
});

To force a deterministic update in CI, serve two builds of the worker script. Load the page against build A, then point the dev server (or a page.route() handler) at build B so the bytes of sw.js differ, and call registration.update(). Because the browser compares the worker script byte-for-byte, any change — even a version comment — triggers a new install.

Cleaning state between PWA tests

Service workers and Cache Storage persist within a context, so a leftover worker from one test can poison the next. Playwright already gives every test a fresh, isolated BrowserContext, which means a clean slate by default. When you reuse a context (for example in a storageState auth setup), unregister workers and clear caches explicitly in a fixture or hook.

import { test as base } from '@playwright/test';

export const test = base.extend({
  page: async ({ page }, use) => {
    await use(page);

    // Teardown: tear down workers and caches so state never leaks.
    await page.evaluate(async () => {
      const regs = await navigator.serviceWorker.getRegistrations();
      await Promise.all(regs.map((r) => r.unregister()));
      const keys = await caches.keys();
      await Promise.all(keys.map((k) => caches.delete(k)));
    });
  },
});

🚀 Level Up Your Playwright

From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.

Asserting PWA installability and the manifest

Installability is governed by the web app manifest plus a registered service worker. Playwright cannot click the native browser install button, but you can assert the prerequisites: the manifest is linked, it parses, and it declares the required fields (name, start_url, icons, and a display of standalone or fullscreen). Fetching the manifest with page.request keeps the check fast and free of UI flakiness.

test('exposes a valid, installable web manifest', async ({ page }) => {
  await page.goto('/');

  const manifestHref = await page
    .locator('link[rel="manifest"]')
    .getAttribute('href');
  expect(manifestHref).toBeTruthy();

  // Fetch and parse the manifest using the request context (no UI needed).
  const response = await page.request.get(manifestHref!);
  expect(response.ok()).toBeTruthy();

  const manifest = await response.json();
  expect(manifest.name).toBeTruthy();
  expect(manifest.start_url).toBeTruthy();
  expect(['standalone', 'fullscreen']).toContain(manifest.display);
  expect(Array.isArray(manifest.icons) && manifest.icons.length).toBeTruthy();

  // A 512px icon is required for a high-quality install on Android.
  const has512 = manifest.icons.some((i: { sizes: string }) =>
    i.sizes.split(' ').includes('512x512'),
  );
  expect(has512).toBeTruthy();
});

For the strongest installability signal, run Lighthouse against the same URL in a separate CI step. Playwright covers the functional behaviour — worker registration, offline rendering, updates, and manifest correctness — while Lighthouse scores the holistic PWA audit. Together they give you confidence that a deploy will not silently break the offline experience.

Quick reference: APIs for Playwright service worker PWA testing

GoalReal Playwright APINotes
List registered workerscontext.serviceWorkers()Returns an array of Worker objects.
Wait for a new workercontext.waitForEvent('serviceworker')Start awaiting before navigation.
Run code in worker scopeworker.evaluate(fn)Access caches, self.registration.
Get worker script URLworker.url()Assert the correct sw.js is used.
Simulate offlinecontext.setOffline(true)Warm the cache online first.
Block workers entirelyserviceWorkers: 'block'Use only for non-PWA mocking suites.
Check manifestpage.request.get(href)Parse JSON and assert fields.

With these patterns in place, Playwright service worker PWA testing becomes a normal part of your suite rather than a manual DevTools chore. Test the worker layer with context.serviceWorkers() and worker.evaluate(), test the user layer with context.setOffline() and role-based assertions, and gate deploys on a valid manifest. Your offline-first promise finally gets the regression coverage it deserves.

FAQ

Does Playwright support service worker testing in all browsers?

No. Service worker inspection via context.serviceWorkers() and the serviceworker event is a Chromium feature in Playwright. Firefox and WebKit do not expose these APIs, so pin your PWA suite to the chromium project. Functional offline behaviour with context.setOffline() works in Chromium, which is where the bulk of PWA testing happens.

Why does my offline test see the browser error page instead of my app?

Almost always a sequencing issue. You went offline before the service worker finished installing and precaching the app shell. Load the page online first, wait for navigator.serviceWorker.controller to become non-null with page.waitForFunction, then call context.setOffline(true) and reload. With a warm cache, the reload is served entirely by the worker.

Can a service worker interfere with page.route() mocks?

Yes. If the worker answers a request from its cache before it reaches the network, your page.route() handler never fires. For pure API-mocking suites, isolate them into a context with serviceWorkers: 'block' so requests always hit your route handlers. Keep the PWA-specific suite separate with serviceWorkers: 'allow'.

🎓 Master Playwright End to End

Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.