Push notifications: a 10-section testing checklist
Push notifications are one of the most under-tested features in mobile apps. A QA checklist usually says “arrived / not arrived”. The reality is dozens of cases: permission states, different app states, deep linking, Do Not Disturb, localization, silent push, attribution in analytics. If any single one is broken — you lose retention and won’t know about it.
Why push is hard to test
- A chain of 4 systems: your backend → APNs / FCM → device OS → your app. Any one can fail independently.
- App state changes behaviour: foreground / background / killed — three different code paths for handling.
- OS-level filters: DND, Focus modes (iOS 15+), Notification channels (Android 8+), Low Power Mode — each can silently drop a push.
- Trigger is often asynchronous: between the backend trigger and a push actually appearing on screen, seconds or minutes pass. “Fell after a minute” is neither a bug nor normal unless measured.
1. Permission states
The main thing to understand — there are more than two statuses. On iOS at least 5: notDetermined, denied, authorized, provisional (silent without permission), ephemeral (App Clip). All must be checked.
- First launch: on cold start a new user sees the prompt? Or do we first show a soft-ask (our own explainer screen), then the system prompt? Apple prefers the latter.
- Permission declined: after
Don't Allowthe app keeps working, doesn’t crash. UI correctly reflects “notifications off”. - Enabling via Settings: user declined → went to Settings → enabled → returned to the app. Will they receive push on the next trigger? Often not — the token never registered retroactively.
- Provisional authorization (iOS 12+): pushes arrive silently in Notification Center; the user later decides to allow “loudly” or deny. Docs: Asking permission to use notifications.
- Notification channels on Android 8+: each channel can be individually disabled. Test each. Docs: Notification channels.
2. App states
Three code branches developers often write once and forget. Test them separately.
- Foreground: app is open, user is playing. On iOS by default the push is not shown — the app must decide itself (toast, in-app banner, or nothing). Test: push during a level → doesn’t break animations, doesn’t steal input focus.
- Background: app is backgrounded, screen locked or not. Push shows in Notification Center. Tap → app opens at the correct screen (see #5 deep linking).
- Killed (force-quit): user swiped the app out of task switcher. Push still arrives. After tap — the app starts cold, and should know what was tapped (on iOS:
launchOptions[.remoteNotification]). Check that the deep-link works after a full cold start.
3. Notification types
- Alert / Banner: regular visual push. Standard case.
- Silent push (content-available): a push with no UI, so the app updates data in the background. Very hard to test — without logging you don’t know it arrived. iOS throttles silent push hard (a few per hour). Verify via Console.app + filter by subsystem.
- Time-sensitive (iOS 15+): pierces Focus modes. Should be reserved for genuinely urgent notifications (Apple is strict — they may reject the app).
- Critical alert (iOS): pierces Mute and DND. Requires a special entitlement from Apple. Almost never used in games.
- Provisional: silent pushes that don’t need permission. Appear in Notification Center, user decides.
4. Localization and dynamic content
- Localized text: if the push is built on the server — it must respect the user’s
locale. If built on the client vialoc-key(iOS) — keys must exist in every Localizable.strings. Test in every language. - Variables in text: “You have 3 lives” — what about 0, 1, 21 lives? Does pluralization work? (see the previous post on localization).
- Emoji and special characters: long names, emoji, RTL text. Doesn’t truncate, doesn’t break rendering.
- Length: iOS Lock Screen shows ~2 lines, Notification Center expands. Long text wraps correctly in both views.
5. Deep linking from push
The most common problem: tap on push opens the app at the home screen instead of the relevant one.
- Push “50% off No-Ads” → tap → must open Shop with the offer highlighted. Not Home.
- Push “Level-up reward” → tap → must open the reward screen. Not Home.
- Push arrives while the user is already on the relevant screen → nothing should “jump”. Refresh data only.
- Push with a deep-link to a screen that requires auth → if signed out → proper “log in first, then deep-link” flow.
- Push with a deep-link to expired content (event over, sale ended) → graceful fallback to Home + informative message.
6. OS-level filters
Test in each of these modes separately.
- Do Not Disturb (DND): push arrives silently in Notification Center, doesn’t ring, doesn’t flash. This is normal. Time-sensitive pierce DND.
- Focus modes (iOS 15+): user can allow notifications only from whitelisted apps. If your app isn’t on the list — push arrives in the “summary” later, not immediately. Test.
- Low Power Mode: iOS throttles background fetch, silent push arrives delayed or is dropped.
- Doze mode (Android 6+): if the device hasn’t moved for ~30 minutes, non-urgent pushes are dropped into maintenance windows. FCM-priority HIGH pierces Doze.
- Airplane mode: accumulated pushes arrive as a batch when network returns. Must display correctly, not duplicate.
7. Analytics and attribution
Without push events, push is a blind marketing bet. These must be logged:
push_received— the app received it (even if the user didn’t open it).push_displayed— the OS displayed it (important: on iOS the app doesn’t know this directly, you need Notification Service Extensions to measure).push_opened— user tapped and the app opened.push_dismissed— user swiped without opening (Android only).
Verification: push with a unique campaign_id → in your analytics you see 4 events with the same campaign_id, and attribution to follow-up actions (purchase, level-up) persists for N hours.
8. Edge cases from production
- User changed OS language after registering the token — pushes arrive in the old language because the server stored the locale at registration time.
- User changed time zone — a push with relative time “in 1 hour” arrives off-schedule. The server must recompute.
- User deleted and reinstalled the app — old push token invalidated. Your server has a “zombie” in the DB. APNs / FCM return
Unregistered— the server must handle this and delete the token. - User granted permission, then revoked it — your server still has the token, but pushes don’t reach. Without feedback you send into the void.
- Multi-device user — should push go to all devices? Only the active one? When to invalidate the old device’s token? It’s a business decision, but the QA scenario is mandatory.
- Duplicates — server accidentally sent the same push twice. Idempotency via
apns-collapse-id(iOS) /collapse_key(FCM) — both collapse identical pushes into one.
9. iOS specifics
- APNs sandbox vs production: development builds hit sandbox APNs; App Store / TestFlight hit production. They’re independent — a sandbox token doesn’t work in production and vice versa. The most common “lost pushes” issue when shipping from TestFlight to App Store.
- Badge count: the number on the icon. Managed separately via
badgein the payload or a client API. QA: after opening a push, the counter resets correctly. - Notification Service Extension: to modify the push on the fly (decoding, fetching media, analytics). If you have one — test it separately.
- Mutable content and media attachments: push with image / video. iOS — via NSE, 5 MB limit.
10. Android specifics
- Notification channels: each channel has its own importance (HIGH = heads-up, LOW = silent). User can disable channels individually in Settings. Test each channel separately.
- OEM fragmentation: Xiaomi, Huawei, Samsung have their own aggressive battery systems. On Xiaomi you must explicitly add the app to “Autostart whitelist” or pushes stop arriving after a few hours. Not a bug in your app — it’s the OS. But the user doesn’t know.
- FCM priority: NORMAL is dropped in Doze, HIGH pierces. Use HIGH only for urgent — Google penalizes apps that abuse it.
- Background restrictions on Android 12+: you have only 10 seconds to start a service from a push handler. If your handler does long work — it’ll crash.
QA checklist for a push feature
- Permission prompt appears at the right moment, decline doesn’t break the app
- Push arrives in foreground / background / killed — all three branches
- Tap in each state opens the correct screen (deep link)
- Localization: 2-3 “heavy” languages, pluralization works
- DND / Focus mode / Low Power: push respects OS settings
- Notification channels on Android: each one disabled-enabled individually
- Badge count changes and resets correctly
- Analytics: 4 events received / displayed / opened / dismissed
- Attribution to follow-up actions persists for N hours
- Delete + reinstall — old token invalidated on the server
- Duplicates are collapsed via collapse-id
- Edge: language change, timezone change, multi-device
Tools for testing
- APNs Tester / Pusher / NWPusher — desktop apps for sending test pushes to iOS without a server.
- Firebase Console Cloud Messaging → Send test message — for FCM/Android, can target a specific token.
- Push Notification Tester — VS Code / JetBrains plugins to send right from your IDE.
- Charles / Proxyman — capture the token-registration request, see what your backend sends to APNs/FCM.
- Console.app on Mac + USB iPhone: filter
subsystem:com.apple.cfnetworkshows even silent pushes.