Skip to main content

How to Test Web Push Notification Preferences

Short answer

Web push spans permission prompts, preference persistence, service worker registration, and GDPR opt-in proof—Playwright cannot deliver real OS notifications in every CI run. Test permission grant/deny flows, preference toggles with probe Assert, stub push delivery in test env, and document manual/device-farm coverage for payload deep links—not notification tray appearance in headless Linux.

Part of Testing Guides by integrations.

Who this is for

PWAs and mobile web apps using Web Push API (FCM web, OneSignal, custom service workers) for transactional or marketing notifications—with in-app preference centers.

Why testing push preferences matters

  • GDPR/CCPA — marketing push without documented opt-in
  • Broken unsubscribe — users still receive after toggle off
  • Permission UX — app crashes when Notification.permission === 'denied'
  • Stale subscriptions — expired push endpoint not cleaned on logout

Complexity map

ScenarioEdge caseWhy tests breakApproach
Permission deniedFeature crashUncaught throwAssert graceful degrade
Permission grantedSW register failSilent no pushProbe subscription row
Unsubscribe toggleDB not updatedStill receivingProbe preference false
LogoutEndpoint orphanedGhost pushesProbe endpoint deleted
Marketing vs transactionalWrong categoryComplianceSeparate toggles + probe
Double opt-inConfirm step skippedIllegal sendTwo-step UI + probe
iOS SafariLimited pushUntested platformDocument manual matrix
VAPID rotationOld endpoints invalidMass failureProbe re-subscribe flow
Deep link payloadURL routingManual onlyStub push handler in test
Service worker updateTwo SW versionsDuplicate handlersProbe single registration

Grant notification permission (Playwright)

test.use({ permissions: ['notifications'] });

test('opt-in saves preference', async ({ page, request }) => {
await page.goto('/settings/notifications');
await page.getByRole('switch', { name: /product updates/i }).check();

await expect.poll(async () => {
const res = await request.get(`/api/test/probe-push-prefs?runId=${runId}`);
return (await res.json()).productUpdates;
}).toBe(true);
});

From Playwright permissions.

Denied permission path

test('denied permission degrades gracefully', async ({ context, page }) => {
await context.clearPermissions();
// Some browsers default deny in headless
await page.goto('/settings/notifications');
await expect(page.getByText(/notifications blocked|enable in browser/i)).toBeVisible();
await expect(page.getByRole('switch', { name: /product updates/i })).toBeDisabled();
});

No uncaught errors in console when permission denied.

Unsubscribe / opt-out

await page.getByRole('switch', { name: /marketing/i }).uncheck();
await expect.poll(() => probePushPref(runId, 'marketing')).toBe(false);

// Stub push sender should not target user—probe last_send log empty
await triggerMarketingCampaignStub(runId);
await expect.poll(() => probePushSent(runId, 'marketing')).toBe(0);

Service worker stub in test env

Full push delivery to OS is unreliable in CI. Stub:

// Test build registers mock SW that writes subscription to /api/test/push-subscribe
await page.evaluate(() => navigator.serviceWorker.register('/test-sw.js'));
await page.getByRole('button', { name: /enable notifications/i }).click();
await expect.poll(() => probePushSubscription(runId)).toMatchObject({ endpoint: expect.any(String) });

Nightly or manual: real FCM test message to physical device.

Logout cleans subscription

await page.getByRole('button', { name: /log out/i }).click();
await expect.poll(() => probePushSubscription(runId)).toBeNull();

Anti-patterns

Anti-patternWhy it failsBetter approach
Assert OS notification trayNot available headlessProbe prefs + stub send
Skip denied permissionProd crashNegative spec
UI toggle onlyDB staleProbe preference row
Marketing on by defaultGDPR riskOpt-in scenario + probe
No logout cleanup testGhost endpointsProbe null after logout

Example scenario

Situation: User opts out of marketing push after previously opting in.

Expected outcome: Preference persisted; stub campaign sends zero messages to user.

Why UI-only automation breaks: Toggle off in UI but probe marketing=true—user still gets pushes.

  1. Arrange: Seed user with marketing push enabled and subscription endpoint.
  2. Act: Uncheck marketing switch in settings.
  3. Assert: Probe marketing false; probe push sent count 0 after campaign stub.

TestChimp workflow: Track notification_type when marketing opt-in path untested in prod slice.

Same Arrange/Act/Assert pattern as expired-coupon checkout.

Connect scenarios to your QA workflow

Capture business rules in markdown test plans and enforce them with seed routes and probe Assert. Link SmartTests with // @Scenario: for requirement traceability. Use /testchimp test on PRs; /testchimp explore on SmartTest paths for non-functional gaps (ExploreChimp).

External references

Frequently asked questions

Can Playwright test web push notifications?

Test permission UI, preference toggles, and service worker registration with probes. Stub push delivery in test env—full OS notification display is manual or device farm.

How do I grant notification permission in tests?

test.use({ permissions: ['notifications'] }) or context.grantPermissions before goto. Assert probe subscription row created.

How do I test permission denied?

clearPermissions, load settings, assert graceful message and disabled controls—no console throw.

How do I verify unsubscribe works?

Uncheck preference, probe false, run stub campaign, probe zero sends to user endpoint.

Marketing vs transactional push?

Separate toggles and probes. Marketing requires explicit opt-in scenario for GDPR—probe consent timestamp.

Logout and push endpoints?

Probe subscription deleted or invalidated on logout—prevent ghost pushes to prior user on shared device.

Marketing opt-in popular in prod but untested?

TrueCoverage notification_type slice shows gap. Add opt-in/out SmartTests via /testchimp evolve.

Apply these patterns in your repo

Run `/testchimp init` to connect TestChimp to your repo, then `/testchimp test` on PRs to turn these patterns into maintained SmartTests. Use `/testchimp evolve` when you want to expand coverage as your app grows.

Start free on TestChimp · Book a demo