Push notifications are a critical component of mobile applications, enabling real-time communication with users even when the app is not actively running. Testing push notifications requires understanding both the technical infrastructure (Firebase Cloud Messaging for Android (as discussed in Mobile Testing in 2025: iOS, Android and Beyond), Apple Push Notification Service for iOS) and the user experience implications. This comprehensive guide covers everything you need to know about testing push notifications effectively.
Understanding Push Notification Architecture
Before diving into testing strategies, it’s essential to understand how push notifications work. The architecture involves several key components:
Client-side components:
- Mobile application with notification permissions
- Device registration token (unique identifier)
- Notification handlers (foreground and background)
Server-side components:
- Application backend server
- Push notification service (FCM/APNs)
- Message queue and delivery system
Notification flow:
- App requests notification permission from user
- Device registers with FCM/APNs and receives token
- App sends token to backend server
- Server sends notification payload to FCM/APNs
- FCM/APNs routes notification to target device
- Device displays notification or triggers app handler
Understanding this flow helps identify critical test points throughout the notification lifecycle.
Firebase Cloud Messaging (FCM) Testing
Firebase Cloud Messaging is Google’s solution for sending notifications to Android, iOS, and web applications. Testing FCM requires validating both configuration and runtime behavior.
FCM Setup Validation
Before testing notification delivery, verify your FCM configuration:
// Verify FCM initialization in your app
import messaging from '@react-native-firebase/messaging';
async function checkFCMSetup() {
try {
// Check if Firebase is properly initialized
const isSupported = await messaging().isDeviceRegisteredForRemoteMessages();
console.log('Device registered for FCM:', isSupported);
// Retrieve and log the FCM token
const token = await messaging().getToken();
console.log('FCM Token:', token);
// Verify APNs token on iOS
(as discussed in [Cross-Platform Mobile Testing: Strategies for Multi-Device Success](/blog/cross-platform-mobile-testing)) (as discussed in [Appium 2.0: New Architecture and Cloud Integration for Modern Mobile Testing](/blog/appium-2-architecture-cloud)) if (Platform.OS === 'ios') {
const apnsToken = await messaging().getAPNSToken();
console.log('APNs Token:', apnsToken);
}
return { success: true, token };
} catch (error) {
console.error('FCM setup error:', error);
return { success: false, error };
}
}
Testing FCM Notifications
Use Firebase Console or server-side code to send test notifications:
// Server-side: Send FCM notification using Admin SDK
const admin = require('firebase-admin');
async function sendFCMNotification(deviceToken, payload) {
const message = {
notification: {
title: 'Test Notification',
body: 'This is a test message',
},
data: {
type: 'test',
timestamp: Date.now().toString(),
action: 'open_screen',
screen: 'home'
},
token: deviceToken,
android: {
priority: 'high',
notification: {
channelId: 'default',
sound: 'default',
color: '#FF0000'
}
},
apns: {
payload: {
aps: {
sound: 'default',
badge: 1,
contentAvailable: true
}
}
}
};
try {
const response = await admin.messaging().send(message);
console.log('Successfully sent message:', response);
return { success: true, messageId: response };
} catch (error) {
console.error('Error sending message:', error);
return { success: false, error };
}
}
FCM Testing Checklist
Test Case | Expected Result | Validation Method |
---|---|---|
Token generation | Valid FCM token received | Log token, verify format |
Token persistence | Token remains consistent | Check token across app restarts |
Token refresh | New token generated when invalidated | Force token refresh, verify update |
Notification delivery (foreground) | App handler triggered | Verify handler execution |
Notification delivery (background) | System notification displayed | Visual verification |
Data-only messages | Background handler triggered | Verify data processing |
High priority messages | Immediate delivery | Measure delivery latency |
Apple Push Notification Service (APNs) Testing
APNs is Apple’s push notification infrastructure for iOS, iPadOS, macOS, and watchOS. Testing APNs requires proper certificates and provisioning profiles.
APNs Configuration
Verify your APNs setup:
// iOS: Register for remote notifications
import UserNotifications
func registerForPushNotifications() {
UNUserNotificationCenter.current().requestAuthorization(
options: [.alert, .sound, .badge]
) { granted, error in
print("Permission granted: \(granted)")
guard granted else { return }
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
}
}
// Handle successful registration
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
let tokenParts = deviceToken.map { data in String(format: "%02.2hhx", data) }
let token = tokenParts.joined()
print("Device Token: \(token)")
// Send token to backend server
sendTokenToServer(token)
}
// Handle registration errors
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
print("Failed to register: \(error)")
}
Testing APNs with curl
You can test APNs directly using HTTP/2:
# Send APNs notification using curl
curl -v \
--header "apns-topic: com.yourapp.bundle" \
--header "apns-push-type: alert" \
--header "apns-priority: 10" \
--header "authorization: bearer $AUTH_TOKEN" \
--data '{"aps":{"alert":{"title":"Test","body":"APNs test message"},"badge":1,"sound":"default"}}' \
--http2 \
https://api.push.apple.com/3/device/$DEVICE_TOKEN
APNs Certificate Testing
Certificate Type | Purpose | Testing Approach |
---|---|---|
Development | Testing in development builds | Use sandbox APNs endpoint |
Production | Production app notifications | Use production APNs endpoint |
VoIP | VoIP push notifications | Test with PushKit framework |
Expired certificates | Error handling | Verify error messages |
Local vs Remote Notifications
Understanding the difference between local and remote notifications is crucial for comprehensive testing.
Local Notifications
Local notifications are scheduled by the app itself:
// React Native: Schedule local notification
import PushNotification from 'react-native-push-notification';
function scheduleLocalNotification() {
PushNotification.localNotificationSchedule({
title: 'Scheduled Notification',
message: 'This notification was scheduled locally',
date: new Date(Date.now() + 60 * 1000), // 1 minute from now
playSound: true,
soundName: 'default',
actions: ['View', 'Dismiss'],
userInfo: {
type: 'local',
action: 'open_detail'
}
});
}
// Test immediate local notification
function showLocalNotification() {
PushNotification.localNotification({
title: 'Immediate Notification',
message: 'This notification appears immediately',
bigText: 'This is the longer text that will be displayed when notification is expanded',
largeIcon: 'ic_launcher',
smallIcon: 'ic_notification'
});
}
Remote Notifications
Remote notifications are sent from a server:
// iOS: Handle remote notification
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userInfo = response.notification.request.content.userInfo
// Extract custom data
if let type = userInfo["type"] as? String,
let action = userInfo["action"] as? String {
handleNotificationAction(type: type, action: action)
}
completionHandler()
}
Comparison Table
Feature | Local Notifications | Remote Notifications |
---|---|---|
Trigger | App-scheduled | Server-sent |
Network required | No | Yes |
Delivery guarantee | High (device-based) | Depends on network |
Dynamic content | Limited | Fully customizable |
User targeting | Device only | Server-side logic |
Testing complexity | Low | Medium to high |
Notification Payload Structure and Validation
Proper payload structure is critical for notification delivery and handling.
FCM Payload Structure
{
"message": {
"token": "device_fcm_token",
"notification": {
"title": "Breaking News",
"body": "New article published",
"image": "https://example.com/image.jpg"
},
"data": {
"article_id": "12345",
"category": "technology",
"priority": "high",
"click_action": "OPEN_ARTICLE"
},
"android": {
"priority": "high",
"ttl": "3600s",
"notification": {
"channel_id": "news_updates",
"color": "#FF5722",
"sound": "notification_sound",
"tag": "news",
"click_action": "OPEN_ARTICLE"
}
},
"apns": {
"headers": {
"apns-priority": "10",
"apns-expiration": "1609459200"
},
"payload": {
"aps": {
"alert": {
"title": "Breaking News",
"body": "New article published",
"launch-image": "notification_image"
},
"badge": 5,
"sound": "notification.caf",
"category": "NEWS_CATEGORY",
"thread-id": "news-thread"
},
"article_id": "12345"
}
}
}
}
APNs Payload Structure
{
"aps": {
"alert": {
"title": "Payment Received",
"subtitle": "$500.00 deposited",
"body": "Your payment has been processed successfully"
},
"badge": 1,
"sound": "payment_success.caf",
"category": "PAYMENT_CATEGORY",
"thread-id": "payment-12345",
"content-available": 1,
"mutable-content": 1
},
"transaction_id": "TXN-67890",
"amount": "500.00",
"timestamp": "2025-10-04T10:30:00Z"
}
Payload Validation Tests
// Validate notification payload
function validateNotificationPayload(payload) {
const errors = [];
// Check required fields
if (!payload.notification?.title) {
errors.push('Missing notification title');
}
if (!payload.notification?.body) {
errors.push('Missing notification body');
}
// Validate title length (FCM limit: 65 chars)
if (payload.notification?.title?.length > 65) {
errors.push('Title exceeds 65 characters');
}
// Validate body length (FCM limit: 240 chars)
if (payload.notification?.body?.length > 240) {
errors.push('Body exceeds 240 characters');
}
// Validate data payload size (4KB limit)
const dataSize = JSON.stringify(payload.data || {}).length;
if (dataSize > 4096) {
errors.push('Data payload exceeds 4KB limit');
}
// Check for reserved keys
const reservedKeys = ['from', 'notification', 'message_type'];
if (payload.data) {
reservedKeys.forEach(key => {
if (key in payload.data) {
errors.push(`Reserved key '${key}' used in data payload`);
}
});
}
return {
valid: errors.length === 0,
errors
};
}
Testing Notification Delivery and Receipt
Delivery testing ensures notifications reach users reliably.
Delivery Verification Methods
// Client-side: Track notification receipt
import messaging from '@react-native-firebase/messaging';
import analytics from '@react-native-firebase/analytics';
messaging().onMessage(async remoteMessage => {
console.log('Notification received (foreground):', remoteMessage);
// Log receipt for analytics
await analytics().logEvent('notification_received', {
notification_id: remoteMessage.data?.notification_id,
type: remoteMessage.data?.type,
timestamp: Date.now()
});
// Send delivery confirmation to server
await confirmNotificationDelivery(remoteMessage.data?.notification_id);
});
messaging().setBackgroundMessageHandler(async remoteMessage => {
console.log('Notification received (background):', remoteMessage);
await analytics().logEvent('notification_received_background', {
notification_id: remoteMessage.data?.notification_id
});
});
Server-side Delivery Tracking
// Track notification delivery status
async function trackNotificationDelivery(notificationId) {
const db = admin.firestore();
try {
await db.collection('notifications').doc(notificationId).update({
delivery_status: 'delivered',
delivered_at: admin.firestore.FieldValue.serverTimestamp(),
delivery_latency_ms: Date.now() - sentTimestamp
});
} catch (error) {
console.error('Failed to track delivery:', error);
}
}
Delivery Test Scenarios
Scenario | Test Method | Success Criteria |
---|---|---|
Immediate delivery | Send notification, measure latency | Received within 5 seconds |
Network offline | Disable network, send notification | Delivered when online |
App force-stopped | Stop app, send notification | Notification appears |
Low battery mode | Enable battery saver, send | Delivery may be delayed |
Multiple notifications | Send burst of 10 notifications | All received in order |
Expired notification | Set short TTL, delay delivery | Notification not delivered |
Testing Notification Actions and Deep Linking
Notifications often include actions that navigate users to specific app screens.
Implementing Notification Actions
// Define notification categories with actions
import PushNotificationIOS from '@react-native-community/push-notification-ios';
PushNotificationIOS.setNotificationCategories([
{
id: 'MESSAGE_CATEGORY',
actions: [
{ id: 'reply', title: 'Reply', options: { foreground: true } },
{ id: 'mark_read', title: 'Mark as Read', options: { foreground: false } },
{ id: 'delete', title: 'Delete', options: { destructive: true } }
]
},
{
id: 'EVENT_CATEGORY',
actions: [
{ id: 'accept', title: 'Accept', options: { foreground: true } },
{ id: 'decline', title: 'Decline', options: { foreground: false } }
]
}
]);
// Handle notification actions
PushNotificationIOS.addEventListener('notificationAction', (notification) => {
const action = notification.action;
const userInfo = notification.userInfo;
switch(action) {
case 'reply':
navigateToChat(userInfo.chat_id);
break;
case 'mark_read':
markMessageAsRead(userInfo.message_id);
break;
case 'delete':
deleteMessage(userInfo.message_id);
break;
}
notification.finish(PushNotificationIOS.FetchResult.NoData);
});
Deep Linking Testing
// Handle deep links from notifications
import { Linking } from 'react-native';
async function handleNotificationDeepLink(notification) {
const deepLink = notification.data?.deep_link;
if (!deepLink) return;
try {
// Validate deep link format
const url = new URL(deepLink);
// Parse deep link parameters
const route = url.pathname;
const params = Object.fromEntries(url.searchParams);
// Navigate to appropriate screen
switch(route) {
case '/article':
navigation.navigate('Article', { id: params.article_id });
break;
case '/profile':
navigation.navigate('Profile', { userId: params.user_id });
break;
case '/settings':
navigation.navigate('Settings', { tab: params.tab });
break;
default:
navigation.navigate('Home');
}
} catch (error) {
console.error('Invalid deep link:', error);
}
}
Action Testing Matrix
Action Type | Test Case | Validation |
---|---|---|
Foreground action | Tap “Reply” | App opens to reply screen |
Background action | Tap “Mark Read” | Action completes, no app launch |
Destructive action | Tap “Delete” | Confirmation shown, action completes |
Text input action | Reply with text | Text sent to handler |
Deep link | Tap notification | Correct screen opened with params |
Invalid deep link | Malformed URL | Graceful fallback to home |
Testing Notification Permissions
Permission handling is critical for notification functionality.
Permission Request Testing
// Request notification permissions
import { PermissionsAndroid, Platform } from 'react-native';
import messaging from '@react-native-firebase/messaging';
async function requestNotificationPermission() {
try {
if (Platform.OS === 'android' && Platform.Version >= 33) {
// Android 13+ requires runtime permission
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS
);
return granted === PermissionsAndroid.RESULTS.GRANTED;
} else if (Platform.OS === 'ios') {
// iOS permission request
const authStatus = await messaging().requestPermission({
alert: true,
badge: true,
sound: true,
provisional: false
});
return authStatus === messaging.AuthorizationStatus.AUTHORIZED ||
authStatus === messaging.AuthorizationStatus.PROVISIONAL;
}
return true; // Android < 13 doesn't require runtime permission
} catch (error) {
console.error('Permission request failed:', error);
return false;
}
}
// Check current permission status
async function checkNotificationPermission() {
const authStatus = await messaging().getNotificationSettings();
return {
enabled: authStatus.authorizationStatus === messaging.AuthorizationStatus.AUTHORIZED,
provisional: authStatus.authorizationStatus === messaging.AuthorizationStatus.PROVISIONAL,
denied: authStatus.authorizationStatus === messaging.AuthorizationStatus.DENIED,
alert: authStatus.alert === 1,
badge: authStatus.badge === 1,
sound: authStatus.sound === 1
};
}
Permission State Testing
Permission State | Test Scenario | Expected Behavior |
---|---|---|
Not determined | First app launch | Permission dialog shown |
Granted | Permission allowed | Notifications delivered |
Denied | Permission rejected | No notifications, graceful degradation |
Provisional (iOS) | Silent permission | Notifications in notification center only |
Revoked | Permission disabled in settings | Prompt to re-enable |
Limited (iOS 15+) | Time-sensitive notifications | Only critical notifications |
Background and Foreground Notification Handling
Notifications behave differently based on app state.
Foreground Handling
// Handle notifications when app is in foreground
messaging().onMessage(async remoteMessage => {
console.log('Foreground notification:', remoteMessage);
// Display custom in-app notification
showInAppNotification({
title: remoteMessage.notification.title,
body: remoteMessage.notification.body,
onPress: () => handleNotificationPress(remoteMessage.data)
});
// Update badge count
if (Platform.OS === 'ios') {
PushNotificationIOS.setApplicationIconBadgeNumber(
remoteMessage.data?.badge_count || 1
);
}
});
Background Handling
// Handle notifications when app is in background
messaging().setBackgroundMessageHandler(async remoteMessage => {
console.log('Background notification:', remoteMessage);
// Process data-only messages
if (!remoteMessage.notification && remoteMessage.data) {
await processBackgroundData(remoteMessage.data);
}
// Update local database
await updateLocalCache(remoteMessage.data);
return Promise.resolve();
});
// Handle notification opening from background/killed state
messaging().onNotificationOpenedApp(remoteMessage => {
console.log('Notification opened (background):', remoteMessage);
handleNotificationNavigation(remoteMessage.data);
});
// Check if notification opened app from killed state
messaging().getInitialNotification().then(remoteMessage => {
if (remoteMessage) {
console.log('Notification opened (killed state):', remoteMessage);
handleNotificationNavigation(remoteMessage.data);
}
});
Silent Notifications and Background Updates
Silent notifications enable background data synchronization.
Implementing Silent Notifications
// iOS: Enable background modes in Info.plist
// <key>UIBackgroundModes</key>
// <array>
// <string>remote-notification</string>
// <string>fetch</string>
// </array>
// Handle silent notification
messaging().setBackgroundMessageHandler(async remoteMessage => {
if (remoteMessage.data?.silent === 'true') {
// Perform background sync
await syncDataInBackground();
// Download content
if (remoteMessage.data?.content_url) {
await downloadContent(remoteMessage.data.content_url);
}
// Update app badge
if (remoteMessage.data?.badge) {
await updateBadgeCount(parseInt(remoteMessage.data.badge));
}
}
});
Silent Notification Payload
{
"message": {
"token": "device_token",
"data": {
"silent": "true",
"sync_type": "messages",
"badge": "5"
},
"apns": {
"headers": {
"apns-priority": "5",
"apns-push-type": "background"
},
"payload": {
"aps": {
"content-available": 1
}
}
},
"android": {
"priority": "normal"
}
}
}
Testing Notification Grouping and Categories
Notification grouping improves user experience by organizing related notifications.
Android Notification Channels
// Create notification channels (Android 8.0+)
import PushNotification from 'react-native-push-notification';
PushNotification.createChannel(
{
channelId: 'messages',
channelName: 'Messages',
channelDescription: 'Notifications for new messages',
playSound: true,
soundName: 'message_sound.mp3',
importance: 4, // High importance
vibrate: true
},
created => console.log(`Channel created: ${created}`)
);
PushNotification.createChannel({
channelId: 'promotions',
channelName: 'Promotions',
channelDescription: 'Promotional notifications',
playSound: false,
importance: 2, // Low importance
vibrate: false
});
iOS Notification Groups
// Create notification content with thread identifier
let content = UNMutableNotificationContent()
content.title = "New Message"
content.body = "You have a new message from John"
content.threadIdentifier = "message-thread-123"
content.summaryArgument = "John"
content.summaryArgumentCount = 1
Error Handling and Edge Cases
Robust error handling ensures notifications work reliably.
Common Error Scenarios
// Comprehensive error handling
async function sendNotificationWithErrorHandling(deviceToken, payload) {
try {
const response = await admin.messaging().send({
token: deviceToken,
...payload
});
return { success: true, messageId: response };
} catch (error) {
console.error('Notification error:', error);
// Handle specific error codes
switch(error.code) {
case 'messaging/invalid-registration-token':
case 'messaging/registration-token-not-registered':
// Token is invalid, remove from database
await removeInvalidToken(deviceToken);
return { success: false, error: 'Invalid token', action: 'removed' };
case 'messaging/message-rate-exceeded':
// Too many messages, implement backoff
return { success: false, error: 'Rate limited', action: 'retry' };
case 'messaging/mismatched-credential':
// Wrong FCM credentials
return { success: false, error: 'Auth error', action: 'check_config' };
case 'messaging/invalid-payload':
// Malformed payload
return { success: false, error: 'Invalid payload', action: 'validate' };
default:
return { success: false, error: error.message, action: 'log' };
}
}
}
Edge Case Testing
Edge Case | Test Approach | Expected Handling |
---|---|---|
Token expired | Send to old token | Error detected, token refreshed |
App uninstalled | Send to uninstalled app | Token marked inactive |
Network timeout | Simulate slow network | Retry with exponential backoff |
Payload too large | Send >4KB payload | Error returned, payload rejected |
Invalid characters | Use emoji/special chars | Properly encoded and displayed |
Concurrent notifications | Send 100+ notifications | All delivered, properly grouped |
Time zone handling | Schedule across zones | Correct local time delivery |
Automated Notification Testing Strategies
Automation improves testing efficiency and coverage.
Integration Testing Framework
// Automated notification testing with Jest
describe('Push Notification Tests', () => {
let testDeviceToken;
beforeAll(async () => {
// Setup test environment
testDeviceToken = await getTestDeviceToken();
});
test('should send and receive notification', async () => {
const notificationId = `test-${Date.now()}`;
// Send notification
const result = await sendTestNotification(testDeviceToken, {
title: 'Test Notification',
body: 'Automated test',
data: { notification_id: notificationId }
});
expect(result.success).toBe(true);
// Wait for delivery
await new Promise(resolve => setTimeout(resolve, 3000));
// Verify receipt
const delivered = await checkNotificationDelivered(notificationId);
expect(delivered).toBe(true);
});
test('should handle invalid token gracefully', async () => {
const invalidToken = 'invalid-token-12345';
const result = await sendTestNotification(invalidToken, {
title: 'Test',
body: 'Should fail'
});
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid token');
});
test('should validate payload structure', () => {
const validPayload = {
notification: { title: 'Test', body: 'Message' },
data: { key: 'value' }
};
const validation = validateNotificationPayload(validPayload);
expect(validation.valid).toBe(true);
expect(validation.errors).toHaveLength(0);
});
test('should reject oversized payload', () => {
const largeData = 'x'.repeat(5000);
const invalidPayload = {
notification: { title: 'Test', body: 'Message' },
data: { large_field: largeData }
};
const validation = validateNotificationPayload(invalidPayload);
expect(validation.valid).toBe(false);
expect(validation.errors).toContain('Data payload exceeds 4KB limit');
});
});
End-to-End Testing
// E2E notification testing with Detox
describe('Notification E2E Tests', () => {
beforeAll(async () => {
await device.launchApp({
permissions: { notifications: 'YES' }
});
});
it('should request notification permission', async () => {
await element(by.id('enable-notifications-btn')).tap();
await expect(element(by.text('Permission Granted'))).toBeVisible();
});
it('should display foreground notification', async () => {
// Send test notification
await sendE2ENotification({
title: 'E2E Test',
body: 'Testing notification display'
});
// Verify in-app notification appears
await waitFor(element(by.text('E2E Test')))
.toBeVisible()
.withTimeout(5000);
});
it('should navigate on notification tap', async () => {
await sendE2ENotification({
title: 'Navigation Test',
data: { deep_link: 'myapp://article/123' }
});
await device.sendToHome();
await device.launchApp({ newInstance: false });
// Tap notification in system tray
await device.openNotification();
// Verify correct screen opened
await expect(element(by.id('article-screen'))).toBeVisible();
});
});
Conclusion
Testing push notifications requires a comprehensive approach covering infrastructure setup, delivery verification, user interaction, and edge case handling. By following the strategies outlined in this guide, you can ensure your push notifications provide a reliable and engaging user experience.
Key testing priorities include verifying FCM/APNs configuration, validating payload structure, testing across different app states, handling permissions properly, and implementing robust error handling. Automated testing frameworks help maintain notification quality as your application evolves.
Remember that push notifications directly impact user engagement and retention, making thorough testing essential for mobile application success.