If you have ever shipped a modern web app and then realized nobody tested the push notification path, the service worker lifecycle, or offline retry behavior, you are not alone. These are the parts of a progressive web app that tend to look fine in ordinary end-to-end tests, until real users hit the edge cases. A happy-path login test does not tell you whether a notification payload wakes the right service worker, whether background sync actually retries after reconnect, or whether your app recovers cleanly when browser state is stale.

In practice, test web push notifications and related PWA features by splitting them into layers. Some behavior belongs in browser automation, some belongs in API or integration tests, and some needs deliberate manual or exploratory coverage because browser policies and permissions make full automation awkward. The goal is not to force every scenario into a single UI test. The goal is to verify the contract between your app, the browser, and your backend.

The tricky part of PWA testing is that the code you write is often not the code that executes when the user is offline, idle, or on a different tab.

What makes these features hard to test

Service workers, push, and background sync sit outside the normal page lifecycle. That gives them their power, but also makes them easy to miss in regular E2E coverage.

1. They run asynchronously and out of band

A service worker can receive events when no tab is active. A push event can arrive when the page is closed. Background sync can fire later, after connectivity returns. Your test harness cannot assume page load equals feature readiness.

2. Browser support and permissions are special

Push and notifications are gated by browser permissions, secure origins, and platform-specific rules. Some browsers support parts of the API differently. Some CI environments do not allow real notifications at all. You need to decide whether your test should verify the app’s integration logic, or the browser’s native UI.

3. Service worker state is sticky

A stale service worker can cache old assets, intercept requests, or keep old logic alive after deployment. That means you need tests for registration, updates, activation, and cache invalidation, not just “the page opens.”

4. Offline behavior is often untested until production

Most teams verify online flows and ignore network transitions. Background sync, queued mutations, and retry logic deserve explicit coverage because they are usually the reason PWAs feel reliable.

A practical test strategy for PWA automation

The simplest way to think about this is by test layer.

UI automation, for user-visible outcomes

Use Playwright or Selenium to validate what users see, such as a notification badge, an inbox item, or a UI toast after sync completes. This is where browser automation is strongest.

API tests, for server-side push and sync endpoints

If your backend stores subscriptions, sends push payloads, or accepts offline mutation queues, test those endpoints directly. It is often easier to assert that the backend enqueued a notification than to verify a native browser notification.

Browser-level checks, for registration and lifecycle

Use automation to inspect service worker readiness, state, and registration behavior. In Chromium-based browsers, Playwright can observe service worker events and page context APIs. Selenium can still drive the app, but it is usually less ergonomic for service worker-specific assertions.

Manual validation, for native UI and platform-specific permission flows

Some things remain hard to automate reliably, especially on macOS, Windows, and mobile browsers. If you need to verify the exact native notification UX, include a small manual matrix.

Testing service worker registration and activation

Before testing push or sync, confirm the service worker exists and is active. This sounds obvious, but a surprising amount of test flakiness comes from racing the registration lifecycle.

A good automation check is to wait until the page has a controller or until registration resolves.

import { test, expect } from '@playwright/test';
test('service worker registers and becomes active', async ({ page }) => {
  await page.goto('https://localhost:3000');

const ready = await page.evaluate(async () => { const reg = await navigator.serviceWorker.ready; return { scope: reg.scope, active: !!reg.active, installing: !!reg.installing, waiting: !!reg.waiting, }; });

expect(ready.active).toBeTruthy(); expect(ready.scope).toContain(‘/’); });

This kind of assertion is valuable because it tells you whether the app is actually operating as a PWA, not just loading static assets. If you deploy a new build, you should also test update behavior. A stale service worker can keep old behavior alive across releases.

What to verify

  • The service worker script is served from the expected scope
  • Registration succeeds on a secure origin
  • The worker reaches active state
  • A new deployment eventually replaces the old worker
  • The app behaves correctly when the controller changes

If your app caches aggressively, include at least one test that simulates a version change. Many “works on my machine” PWA bugs are just service worker update bugs.

Testing push subscription flow

Push notification testing usually starts with subscription management. The browser creates a push subscription, your app sends it to the backend, and the backend stores it for future push delivery.

This is where many teams mistakenly stop. They verify that subscribe() returned a payload, but they never verify the server saved it correctly or that permission denial is handled gracefully.

What to test in the browser

  • Notification permission request appears at the right time
  • User denial is handled without breaking the page
  • Subscription succeeds when permission is granted
  • Subscription data is sent to the backend
  • Existing subscriptions are reused or refreshed as intended

Playwright example for permission and subscription flow

import { test, expect } from '@playwright/test';
test('user can subscribe to push notifications', async ({ page, context }) => {
  await context.grantPermissions(['notifications'], { origin: 'https://localhost:3000' });
  await page.goto('https://localhost:3000/settings/notifications');

await page.getByRole(‘button’, { name: ‘Enable notifications’ }).click();

await expect(page.getByText(‘Notifications enabled’)).toBeVisible(); });

This test only proves the user-facing path. For real confidence, pair it with an API assertion that the subscription reached your backend.

import { test, expect } from '@playwright/test';
test('push subscription is persisted on the server', async ({ page, request, context }) => {
  await context.grantPermissions(['notifications'], { origin: 'https://localhost:3000' });
  await page.goto('https://localhost:3000/settings/notifications');
  await page.getByRole('button', { name: 'Enable notifications' }).click();

const res = await request.get(‘https://localhost:3000/api/push-subscriptions’); expect(res.ok()).toBeTruthy();

const body = await res.json(); expect(body.subscriptions.length).toBeGreaterThan(0); });

Edge cases worth covering

  • Permission denied, then later enabled in browser settings
  • Existing expired subscription returned by the backend
  • Subscription refresh after browser reset or profile change
  • Duplicate subscriptions from multiple tabs
  • User signs out, subscription should be cleared or reassigned according to product rules

How to test web push notifications themselves

There are two different things people mean when they say push notification testing.

  1. Testing that your backend can send a push message
  2. Testing that the browser receives it and the app handles it correctly

Those are not the same.

Server-side push delivery tests

At the backend layer, test that your app signs and sends a push request to the push service, handles invalid subscriptions, and retries or removes dead endpoints. These can be ordinary integration tests using your push library and a mocked or test push endpoint.

The important thing is to validate business logic, not the browser’s native notification UI.

Client-side push event tests

If you want to verify how the app reacts when a push arrives, simulate the push event in the service worker. In Chrome-based automation, you can use CDP in some scenarios, but a simpler approach is to expose a test-only hook in your service worker or app shell.

A useful pattern is to keep the notification rendering logic in a pure function that accepts payload data and returns the message model your app uses.

export function buildNotificationModel(payload: { title: string; body: string }) {
  return {
    title: payload.title,
    body: payload.body,
  };
}

Then you can unit test the model logic, while browser tests verify that the service worker uses it.

What not to over-automate

Do not waste time trying to fully assert the native browser notification popup in every CI run. In many environments, that is brittle or impossible. Instead, verify that:

  • the backend sends the push event,
  • the service worker receives or processes the event,
  • the app records the expected side effect,
  • any visible UI update happens.

That is enough for most teams.

Testing background sync and offline retry behavior

Background sync is one of the best reasons to build a PWA, and one of the easiest features to assume works without testing.

The usual flow looks like this:

  1. User performs an action while offline, such as submitting a form or saving a draft
  2. The app stores the request locally or queues it
  3. The service worker registers a sync task
  4. When connectivity returns, the browser fires the sync event
  5. The app retries the request and updates the UI

What to validate

  • The mutation is queued when offline
  • The queue survives page reloads
  • The sync registration succeeds when supported
  • The queued request is retried after reconnection
  • Failed retries are handled predictably, without infinite loops

Playwright approach for offline simulation

Playwright can emulate offline mode in Chromium, which is useful for this kind of test.

import { test, expect } from '@playwright/test';
test('offline action is queued and later synced', async ({ page, context }) => {
  await page.goto('https://localhost:3000/compose');

await context.setOffline(true); await page.getByLabel(‘Message’).fill(‘Draft message’); await page.getByRole(‘button’, { name: ‘Save’ }).click();

await expect(page.getByText(‘Saved offline’)).toBeVisible();

await context.setOffline(false); await page.reload(); await expect(page.getByText(‘Synced’)).toBeVisible(); });

This test is intentionally simple. The key idea is to verify the user-visible result of queueing and retry, not the browser’s internal sync scheduler.

Testing queue persistence

If your app uses IndexedDB or local storage to hold pending work, you should test that the queue survives navigation and browser restarts. For that, the browser context matters as much as the page.

Some useful assertions are:

  • pending items still exist after reload
  • one successful retry removes a single queued item
  • duplicate sync events do not create duplicate server writes
  • an invalid payload is rejected and surfaced clearly

Common background sync failure modes

  • Sync event never fires because the browser does not support it in that environment
  • Registration fails silently because the app did not handle the promise rejection
  • Offline queue and server reconciliation drift apart
  • Replayed requests create duplicate records
  • Service worker update changes queue format unexpectedly

Service worker testing in CI/CD

A lot of PWA bugs appear only when the app is deployed behind HTTPS, with real build artifacts, and with browser caches in play. That is why service worker testing belongs in CI, not just local dev.

The browser automation suite should run against the production-like build, not the dev server if the dev server behaves differently.

A minimal GitHub Actions example

name: pwa-e2e

on: push: branches: [main] pull_request:

jobs: test: 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 - run: npm run start & - run: npx playwright install –with-deps - run: npx playwright test

A few practical notes:

  • Serve the app over HTTPS if the feature requires it
  • Use a stable test fixture account, but isolate browser contexts per test
  • Reset backend state between tests, especially for subscriptions and queued mutations
  • Keep a separate smoke suite for service worker registration and push subscription flow

Why CI can be flaky here

CI often struggles with timing around registration and sync events. The fix is usually not “add more sleeps.” Instead:

  • wait for explicit browser state, such as navigator.serviceWorker.ready
  • poll the backend for the side effect you expect
  • use test IDs or API state instead of relying on native UI timing
  • keep a longer timeout for the small number of PWA tests that need it

Selenium versus Playwright for these features

I still see teams reaching for Selenium because it is already in their stack, and that is reasonable. Selenium can drive the user flow and verify visible outcomes. For pure PWA automation, though, Playwright usually gives you a smoother path because it has stronger primitives for browser contexts, permissions, offline mode, and modern JavaScript app behavior.

When Selenium is enough

  • You only need to verify a user flow that ends in a visible UI state
  • Push and sync are already covered by API or unit tests
  • Your team standardizes on Selenium and does not want another framework

When Playwright is a better fit

  • You need permission control for notifications
  • You need offline network emulation
  • You need easier context isolation for service worker tests
  • You want a more direct path to inspecting app-side browser APIs

For many teams, the best answer is not replacement, but layering. Keep Selenium where it already works, and use Playwright for the PWA-specific tests that Selenium makes awkward.

A test matrix that actually helps

Instead of trying to test every permutation, define a small matrix with the scenarios that matter most.

Suggested coverage matrix

Feature Happy path Failure path Lifecycle path
Service worker registers and activates registration fails on bad scope update and controller change
Push subscription permission granted, subscription saved denied permission, no crash refresh or re-subscribe
Push delivery backend sends payload invalid subscription removed app updates UI on event
Background sync queued offline, retried online sync registration fails duplicate event does not duplicate write

This is enough to catch the bugs that usually escape ordinary E2E suites.

Debugging tips that save time

When these tests fail, the issue is often not in the test itself. It is in the assumptions.

Inspect the service worker lifecycle

If a page is acting strangely, check whether an older service worker is still controlling it. Clear browser storage or version your worker update logic carefully.

Log push subscription state

Record whether a subscription exists, whether the browser permission is granted, denied, or default, and whether the backend thinks the endpoint is active.

Separate browser problems from app problems

If a notification does not appear, confirm whether the browser even received the event. If the backend says it sent the payload, but the UI never changes, the bug may be in the service worker handler or in client state reconciliation.

Keep test data deterministic

Push payloads and queued jobs should be easy to assert against. Use stable IDs and predictable timestamps where possible.

A sensible testing split for teams

If you are deciding how much to invest here, this is the balance I recommend:

  • Unit test the pure logic that formats payloads, deduplicates queue entries, and maps sync results
  • Integration test backend push delivery, subscription storage, and replay handling
  • E2E test the user-visible paths around enabling notifications and handling offline actions
  • Manually verify native notification UX on the browsers and devices that matter most

That split gives you practical confidence without turning your test suite into a browser feature museum.

Final thoughts

Modern web apps do not stop at page renders and button clicks. Once you add service workers, push, and background sync, you are testing distributed behavior across browser internals, backend state, and offline recovery logic. That is exactly why these features are worth explicit attention.

If you want your suite to be useful, not just long, focus on the transitions that matter: registration, permission, subscription persistence, offline queueing, retry, and update. Those are the moments where user trust is won or lost.

A well-designed plan to test web push notifications and the surrounding PWA behaviors will not try to simulate the entire browser. It will verify the parts of the contract your app owns, and let the browser do the rest.

For background reading on the broader testing discipline, see software testing, test automation, and continuous integration.