Build-an-X for push notification tests (push notifications, web push, FCM / APNs push messages) across Web Push (RFC 8030 / VAPID), Apple Push Notification Service (APNs), and Firebase Cloud Messaging (FCM) - covers subscription handshake, payload encryption, badge / sound / click-action assertions, expired-subscription cleanup, silent-vs-alert, and topic-vs-targeted routing. Use when authoring tests for any push notification flow.
75
94%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Three push platforms dominate:
| Platform | Standard / Provider |
|---|---|
| Web Push | IETF RFC 8030 + VAPID (RFC 8292) |
| APNs (iOS / iPadOS / macOS) | Apple proprietary HTTP/2 |
| FCM (Android + cross-platform) | Google Firebase |
Each platform has distinct test patterns; Step 1 picks the isolation level and Steps 2 - 4 cover the per-platform test approach.
| Level | Example | Tradeoffs |
|---|---|---|
| Mock the SDK | Patch FCM/APNs/Web-Push library send method | Fast; misses provider-side behavior |
| Sandbox / emulator | APNs sandbox, FCM emulator (limited), web-push test browser | Realistic; slower |
| End-to-end with test devices | Real device farm | Highest fidelity; expensive + flaky |
Default: mock the SDK - fast, deterministic, covers payload-shape + error-path logic which is most of what regressions hit. Use sandbox/emulator when verifying provider-side behavior (encryption, rate-limit, real 410 handling); use real device farms only for grouped-notification / channel UX work.
Per IETF RFC 8030 (Web Push Protocol): the user-agent subscribes via pushManager.subscribe(), the app server sends an encrypted (RFC 8291) + VAPID-signed (RFC 8292) push to the returned endpoint, and the service worker's push event calls self.registration.showNotification(). Status code 410 Gone means the subscription is invalid (user revoked or expired); the app must remove it from storage.
Mock webPush.sendNotification and assert payload shape plus the 410 cleanup path; add a service-worker harness test that dispatches a synthetic PushEvent. Full Node.js + service-worker recipes: references/platform-test-patterns.md.
Apple Push Notification Service has two environments per developer.apple.com/documentation/usernotifications:
| Environment | Use |
|---|---|
api.sandbox.push.apple.com | Development; uses development APNs certificate |
api.push.apple.com | Production |
Tests typically run against sandbox + use a development APNs
certificate or Auth Key (.p8 file). Mock apns_client.send, assert the aps payload shape (alert title / body, sound), and cover the HTTP 410 (Unregistered) token-cleanup path. Python recipes: references/platform-test-patterns.md.
Firebase Cloud Messaging supports HTTP v1 API + legacy HTTP API.
Use HTTP v1 for new code (legacy deprecated). Mock admin.messaging().send, assert the cross-platform message shape (token, notification, data, android, apns), and test the messaging/registration-token-not-registered cleanup path (same as APNs Step 3). Node.js recipe: references/platform-test-patterns.md.
Tests should distinguish the two and assert correct payload:
it('uses content-available for background sync', () => {
const payload = buildSilentSyncPush();
expect(payload.aps['content-available']).toBe(1);
expect(payload.aps.alert).toBeUndefined(); // no UI for silent
});The push payload includes a click-action / URL that opens a specific app screen. Test that the right deep-link is in the payload:
def test_order_push_deep_links_to_order_screen():
payload = build_order_push(order_id=123)
assert payload["data"]["click_action"] == "/orders/123"End-to-end click-action tests require device automation (Espresso /
XCUITest); cross-ref appium-testing (in the qa-mobile plugin).
FCM supports topic subscriptions (broadcast to all subscribers of a topic) vs targeted (single device token). Tests for topic routing:
it('subscribes user to order-updates topic', async () => {
const subSpy = jest.spyOn(admin.messaging(), 'subscribeToTopic')
.mockResolvedValue({ successCount: 1, failureCount: 0, errors: [] });
await subscribeToOrderUpdates('user-device-token', userId);
expect(subSpy).toHaveBeenCalledWith(['user-device-token'], `user-${userId}`);
});For each push channel:
An e-commerce app sends a Web Push "order shipped" notification and must clean up revoked subscriptions.
webPush.sendNotification (Step 1 default).pushOrderUpdate(sub, { orderId: 123, status: 'shipped' }) and asserts sendNotification was called with a payload string containing "orderId":123. The mock returns { statusCode: 201 }; the assertion passes.sendNotification to reject with { statusCode: 410 }. After pushOrderUpdate runs, the test asserts the subscription row is gone from storage (Subscription.findOne(...) returns null), proving the 410 cleanup path per RFC 8030.PushEvent whose data.json() returns the order payload; assert self.registration.showNotification was called with body shipped.| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Test only happy path | Miss expired-token cleanup; storage grows; spam to dead devices | Step 2-4 410 / 404 / unregistered tests |
| Hardcode VAPID public key in tests + checked into repo | Key rotation breaks tests | Inject via env var |
| Send to real production APNs in tests | Real users get test push | Sandbox environment (Step 3) |
| Skip silent-vs-alert distinction | Apple throttles silent push; missing flag → notifications dropped | content-available test (Step 5) |
| Skip click-action test | Deep links break silently after refactors | Step 6 |
email-flow-test-author,
sms-test-author - sister channelsappium-testing,
xcuitest-suite,
espresso-suite - device-side click-action verification