PWA Push Notification Testing: A Complete Technical Guide
Push notifications are one of the most impactful PWA features — and one of the most painful to test. They involve permission APIs, service worker push event handlers, Web Push Protocol, notification click handling, and platform-specific rendering quirks. Getting them wrong means users miss critical updates or, worse, get spammed into revoking permissions. This guide covers how to test push notifications properly.
The Push Notification Stack
Before testing, understand what you're testing:
- Permission Request —
Notification.requestPermission() - Subscription —
PushManager.subscribe()with VAPID keys - Backend — your server stores subscriptions and sends push messages
- Service Worker Push Event — handles incoming push, creates notification
- Notification Click — service worker
notificationclickhandles user interaction - Notification Close — optional tracking of dismissals
Each layer can fail independently. Your tests need to cover all of them.
Testing Permission Flows
Playwright Permission Control
Playwright lets you preset browser permissions, which is essential for testing different permission states:
import { test, expect } from '@playwright/test';
test.describe('Push Notification Permissions', () => {
test('shows subscribe button when notifications not yet granted', async ({ browser }) => {
const context = await browser.newContext({
permissions: [] // No permissions granted
});
const page = await context.newPage();
await page.goto('http://localhost:3000/settings');
await expect(page.locator('[data-testid="enable-notifications"]'))
.toBeVisible();
await expect(page.locator('[data-testid="notifications-enabled"]'))
.toBeHidden();
});
test('shows enabled state when permission already granted', async ({ browser }) => {
const context = await browser.newContext({
permissions: ['notifications']
});
const page = await context.newPage();
await page.goto('http://localhost:3000/settings');
await expect(page.locator('[data-testid="notifications-enabled"]'))
.toBeVisible();
});
test('handles permission denial gracefully', async ({ browser }) => {
const context = await browser.newContext({
permissions: [] // Start ungranted
});
const page = await context.newPage();
// Mock the permission request to return 'denied'
await page.evaluate(() => {
Notification.requestPermission = async () => 'denied';
});
await page.goto('http://localhost:3000/settings');
await page.click('[data-testid="enable-notifications"]');
await expect(page.locator('[data-testid="permission-denied-message"]'))
.toBeVisible();
await expect(page.locator('[data-testid="permission-denied-message"]'))
.toContainText('You can enable notifications in browser settings');
});
});Testing Subscription Creation
Mocking PushManager
The real PushManager requires a service worker and valid VAPID keys. For unit tests, mock it:
// tests/mocks/push-manager.js
export function mockPushManager(options = {}) {
const defaultSubscription = {
endpoint: 'https://fcm.googleapis.com/fcm/send/mock-endpoint',
keys: {
p256dh: 'mock-p256dh-key',
auth: 'mock-auth-key'
},
toJSON: () => ({
endpoint: 'https://fcm.googleapis.com/fcm/send/mock-endpoint',
keys: { p256dh: 'mock-p256dh-key', auth: 'mock-auth-key' }
}),
unsubscribe: jest.fn().mockResolvedValue(true)
};
return {
subscribe: jest.fn().mockResolvedValue(defaultSubscription),
getSubscription: jest.fn().mockResolvedValue(
options.existingSubscription ? defaultSubscription : null
),
permissionState: jest.fn().mockResolvedValue('granted')
};
}// tests/subscription.test.js
import { subscribeUser } from '../src/push/subscription';
import { mockPushManager } from './mocks/push-manager';
describe('Push Subscription', () => {
let mockManager;
beforeEach(() => {
mockManager = mockPushManager();
// Mock service worker registration
Object.defineProperty(navigator, 'serviceWorker', {
value: {
ready: Promise.resolve({
pushManager: mockManager
})
}
});
});
it('creates new subscription with VAPID key', async () => {
const subscription = await subscribeUser();
expect(mockManager.subscribe).toHaveBeenCalledWith({
userVisibleOnly: true,
applicationServerKey: expect.any(Uint8Array)
});
expect(subscription.endpoint).toContain('fcm.googleapis.com');
});
it('returns existing subscription if already subscribed', async () => {
mockManager = mockPushManager({ existingSubscription: true });
navigator.serviceWorker.ready = Promise.resolve({
pushManager: mockManager
});
const subscription = await subscribeUser();
// Should not call subscribe() again
expect(mockManager.subscribe).not.toHaveBeenCalled();
expect(mockManager.getSubscription).toHaveBeenCalled();
});
it('sends subscription to backend', async () => {
global.fetch = jest.fn().mockResolvedValue({ ok: true });
await subscribeUser();
expect(global.fetch).toHaveBeenCalledWith('/api/subscriptions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: expect.stringContaining('endpoint')
});
});
});Testing the Service Worker Push Event Handler
This is where most push notification bugs live. Test the push event handler in isolation:
import makeServiceWorkerEnv from 'service-worker-mock';
describe('Push Event Handler', () => {
beforeEach(() => {
Object.assign(global, makeServiceWorkerEnv());
jest.resetModules();
require('../sw.js');
});
it('shows notification when push event fires', async () => {
const showNotification = jest.spyOn(self.registration, 'showNotification');
const pushEvent = new PushEvent('push', {
data: new PushMessageData(JSON.stringify({
title: 'New Message',
body: 'You have a new message from Alice',
icon: '/icon.png',
url: '/messages/123'
}))
});
await self.trigger('push', pushEvent);
expect(showNotification).toHaveBeenCalledWith('New Message', {
body: 'You have a new message from Alice',
icon: '/icon.png',
data: { url: '/messages/123' }
});
});
it('shows fallback notification when payload is missing', async () => {
const showNotification = jest.spyOn(self.registration, 'showNotification');
const pushEvent = new PushEvent('push', { data: null });
await self.trigger('push', pushEvent);
expect(showNotification).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ body: expect.any(String) })
);
});
it('handles malformed push payload without crashing', async () => {
const pushEvent = new PushEvent('push', {
data: new PushMessageData('not-valid-json{{{')
});
// Should not throw
await expect(self.trigger('push', pushEvent)).resolves.not.toThrow();
});
it('does not show notification when app is in foreground', async () => {
const showNotification = jest.spyOn(self.registration, 'showNotification');
// Mock client (page) as visible
self.clients.matchAll = jest.fn().mockResolvedValue([
{ visibilityState: 'visible', focused: true }
]);
const pushEvent = new PushEvent('push', {
data: new PushMessageData(JSON.stringify({
title: 'Update',
body: 'App is open — handle this in-page'
}))
});
await self.trigger('push', pushEvent);
// App is in foreground, so we might handle it in-page instead
expect(showNotification).not.toHaveBeenCalled();
});
});Testing Notification Click Handling
When a user clicks a push notification, the service worker notificationclick event fires. Test it:
describe('Notification Click Handler', () => {
beforeEach(() => {
Object.assign(global, makeServiceWorkerEnv());
require('../sw.js');
});
it('opens target URL when notification is clicked', async () => {
const openWindow = jest.spyOn(self.clients, 'openWindow').mockResolvedValue(null);
const notification = {
data: { url: '/messages/123' },
close: jest.fn()
};
const clickEvent = new NotificationEvent('notificationclick', { notification });
await self.trigger('notificationclick', clickEvent);
expect(openWindow).toHaveBeenCalledWith('/messages/123');
expect(notification.close).toHaveBeenCalled();
});
it('focuses existing window if already open', async () => {
const mockWindow = { url: 'http://localhost:3000/messages/123', focus: jest.fn() };
self.clients.matchAll = jest.fn().mockResolvedValue([mockWindow]);
const notification = {
data: { url: '/messages/123' },
close: jest.fn()
};
const clickEvent = new NotificationEvent('notificationclick', { notification });
await self.trigger('notificationclick', clickEvent);
expect(mockWindow.focus).toHaveBeenCalled();
// Should not open new window
expect(self.clients.openWindow).not.toHaveBeenCalled();
});
it('handles notification close/dismiss', async () => {
const notification = {
data: { notificationId: 'notif-456' },
close: jest.fn()
};
global.fetch = jest.fn().mockResolvedValue({ ok: true });
const closeEvent = new NotificationEvent('notificationclose', { notification });
await self.trigger('notificationclose', closeEvent);
// Should track dismissal
expect(global.fetch).toHaveBeenCalledWith(
'/api/notifications/notif-456/dismiss',
expect.objectContaining({ method: 'POST' })
);
});
});End-to-End Push Notification Testing
For realistic E2E testing, you need to simulate the push from the server side. Use Playwright with a test notification endpoint:
test('full push notification flow', async ({ browser }) => {
const context = await browser.newContext({
permissions: ['notifications']
});
const page = await context.newPage();
await page.goto('http://localhost:3000');
// Wait for SW to register
const swRegistration = await page.evaluate(async () => {
await navigator.serviceWorker.ready;
return true;
});
expect(swRegistration).toBe(true);
// Subscribe
await page.click('[data-testid="enable-notifications"]');
// Get the subscription from the page
const subscription = await page.evaluate(async () => {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
return sub?.toJSON();
});
expect(subscription?.endpoint).toBeTruthy();
// Trigger a push via your test API
const response = await fetch('http://localhost:3000/api/test/send-push', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subscription,
payload: {
title: 'Test Notification',
body: 'This is a test push',
url: '/test-destination'
}
})
});
expect(response.ok).toBe(true);
// Capture the notification (Playwright experimental feature)
const notification = await page.waitForEvent('notification', { timeout: 5000 });
expect(notification.title()).toBe('Test Notification');
});Testing Notification Payloads
Your backend needs to send correctly formatted Web Push payloads. Test this server-side:
// tests/api/push-sender.test.js
import { sendPushNotification } from '../../src/api/push';
import webpush from 'web-push';
jest.mock('web-push');
describe('Push Sender', () => {
it('sends push with correct VAPID configuration', async () => {
webpush.sendNotification.mockResolvedValue({ statusCode: 201 });
const subscription = {
endpoint: 'https://fcm.googleapis.com/fcm/send/mock',
keys: { p256dh: 'key', auth: 'auth' }
};
await sendPushNotification(subscription, {
title: 'Alert',
body: 'Something happened',
url: '/alerts/1'
});
expect(webpush.sendNotification).toHaveBeenCalledWith(
subscription,
expect.stringContaining('"title":"Alert"'),
expect.objectContaining({
vapidDetails: expect.objectContaining({
subject: 'mailto:support@yourapp.com'
})
})
);
});
it('handles expired subscription by removing from database', async () => {
webpush.sendNotification.mockRejectedValue({
statusCode: 410, // Gone — subscription expired
body: 'push subscription has unsubscribed or expired'
});
const removeSubscription = jest.spyOn(db, 'removeSubscription');
const subscription = { endpoint: 'https://expired.example.com/push/abc' };
await sendPushNotification(subscription, { title: 'Test' });
expect(removeSubscription).toHaveBeenCalledWith('https://expired.example.com/push/abc');
});
});Cross-Browser and Platform Testing
Push notifications behave differently across platforms. Key differences to test:
| Platform | Behavior |
|---|---|
| Chrome (desktop) | Full support, notification appears in OS notification center |
| Firefox | Requires user gesture to request permission |
| Safari (macOS 13+) | Supported via WebKit implementation |
| iOS Safari (16.4+) | Requires PWA to be added to home screen |
| Android Chrome | Full support, rich notifications |
For iOS, specifically test:
test('shows add-to-homescreen prompt on iOS before notifications', async ({ browser }) => {
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
});
const page = await context.newPage();
await page.goto('http://localhost:3000/settings');
// On iOS, push requires the app to be installed
await expect(page.locator('[data-testid="ios-install-prompt"]'))
.toBeVisible();
});Automated Monitoring with HelpMeTest
Push notifications are especially prone to silent failures — your subscription data goes stale, VAPID keys expire, or your push server configuration breaks without any obvious error to users. HelpMeTest can monitor this:
*** Test Cases ***
Push Notification Health Check
[Documentation] Verify push subscription API is working
${response}= Make HTTP Request POST /api/subscriptions/test
... body={"test": true}
Should Be Equal ${response.status_code} 200
Push Notification Permission Flow
Open Browser https://your-pwa.com chromium
Grant Permission notifications
Wait For Element css:[data-testid="enable-notifications"]
Click Element css:[data-testid="enable-notifications"]
Wait For Element css:[data-testid="notifications-enabled"]
Element Should Be Visible css:[data-testid="notifications-enabled"]Run this daily to catch infrastructure issues before users report missing notifications.
Common Push Notification Testing Pitfalls
- Testing without
userVisibleOnly: true— Chrome requires this, and tests that omit it will fail silently in some environments. - Not testing subscription expiry handling — Push endpoints expire. Your backend must handle 410 responses by removing stale subscriptions.
- Forgetting to test background vs. foreground behavior — Many apps want to handle notifications differently based on whether the app is open.
- Not testing bad payloads — Your push handler will receive malformed payloads eventually. Test that it doesn't crash the service worker.
- Testing only Chrome — Push notification behavior on Firefox, Safari, and iOS Safari has meaningful differences.
Conclusion
Push notification testing is a multi-layer challenge. Unit test your service worker push and click handlers. Integration test your subscription creation and backend sending. Use Playwright for realistic E2E scenarios, and test permission states explicitly.
The investment pays off: push notifications that work reliably are a major PWA differentiator. The ones that break silently erode user trust permanently — users who revoke notification permissions almost never re-grant them.