Push Notification Testing Best Practices: End-to-End Delivery, Silent Push, Background Fetch, and Analytics
Push notification testing goes far beyond unit-testing your send function. This guide covers the full spectrum: verifying end-to-end delivery without a real device, testing silent push and background fetch correctly, asserting deep link behavior when notifications are tapped, validating badge count updates, and building testable notification analytics pipelines — all for iOS, Android, and Web Push.
Key Takeaways
End-to-end delivery testing requires a receipt verification step, not just a successful send response. A 200 from FCM or APNS confirms the message was accepted, not delivered; testing receipt APIs or Firebase Notification Delivery reports is what closes the loop.
Silent push testing must assert the app performs work, not just that the OS accepted the push. A content-available push that the OS delivers but the app ignores produces no user value; test the data handler, not the push receipt.
Deep link testing belongs in push notification tests. If a notification tap routes to /orders/42 but that route is broken, the push notification is effectively broken — your notification tests should cover the routing outcome.
Badge count tests must cover both increment and reset. Setting badge to a specific number and clearing it to zero are separate code paths that both fail in production independently; test both explicitly.
Notification analytics tests must validate the data pipeline, not the display. Open rate, delivery rate, and conversion rate are computed from raw events; test that events are emitted correctly and that the aggregation logic produces correct rates under various success/failure mixes.
Push notifications are one of the highest-leverage features in mobile and web apps — and one of the most fragile in production. Delivery fails silently. Deep links break after refactors. Badge counts drift out of sync. Analytics show 0% open rates because the event handler was never wired up. This guide provides a testing strategy that catches all of these before they reach users.
End-to-End Delivery Verification
A 200 response from your push provider does not mean the notification was delivered. It means the provider accepted the message. Real delivery verification requires checking delivery receipts.
FCM Delivery Analytics
# test_fcm_delivery_analytics.py
from unittest.mock import patch, MagicMock
import pytest
from myapp.analytics.fcm_analytics import FCMDeliveryAnalytics
def test_delivery_analytics_queries_correct_app_instance():
analytics = FCMDeliveryAnalytics(project_id="my-project")
with patch("myapp.analytics.fcm_analytics.bigquery.Client") as mock_bq:
mock_query_result = [
MagicMock(message_id="msg-001", delivered=True, opened=True),
MagicMock(message_id="msg-002", delivered=True, opened=False),
MagicMock(message_id="msg-003", delivered=False, opened=False),
]
mock_bq.return_value.query.return_value = mock_query_result
report = analytics.get_campaign_report(campaign_id="campaign-abc")
assert report["sent"] == 3
assert report["delivered"] == 2
assert report["opened"] == 1
assert report["delivery_rate"] == pytest.approx(66.67, abs=0.1)
assert report["open_rate"] == pytest.approx(50.0)
def test_delivery_receipt_stored_on_send():
"""Verify that every send operation stores a receipt for later analytics."""
from myapp.push_service import FCMPushService
from myapp.models import NotificationReceipt
service = FCMPushService()
with patch("myapp.push_service.messaging") as mock_messaging:
mock_messaging.send.return_value = "projects/myapp/messages/msg-xyz"
service.send_and_track(
token="device-token",
title="Your ride is here",
body="Driver is 2 minutes away",
campaign_id="rides-arrival"
)
receipt = NotificationReceipt.objects.get(message_id="msg-xyz")
assert receipt.campaign_id == "rides-arrival"
assert receipt.delivered_at is not None
assert receipt.opened_at is None # Not opened yetAPNS Delivery Testing with Feedback Service
# test_apns_feedback.py
from unittest.mock import patch, MagicMock
import pytest
from datetime import datetime
from myapp.apns_feedback import APNSFeedbackProcessor
def test_feedback_processor_removes_invalid_tokens():
processor = APNSFeedbackProcessor()
invalid_tokens = [
{"device_token": "dead-token-a" * 4, "timestamp": datetime.now()},
{"device_token": "dead-token-b" * 4, "timestamp": datetime.now()},
]
with patch.object(processor, "fetch_feedback", return_value=invalid_tokens):
with patch.object(processor, "remove_tokens") as mock_remove:
processor.process()
removed = mock_remove.call_args[0][0]
assert len(removed) == 2
assert "dead-token-a" * 4 in removedTesting Silent Push and Background Fetch
Silent pushes (called "background pushes" on iOS) wake the app to perform work without showing a visible notification. Testing that the correct work happens — not just that the push was accepted — is the key challenge.
# test_silent_push.py
from unittest.mock import patch, MagicMock, call
import pytest
from myapp.push_handlers import handle_background_data_push
def test_silent_push_triggers_content_sync():
"""A silent push with sync_required=true should trigger a content sync."""
push_data = {
"type": "content_sync",
"content_id": "article-9912",
"sync_required": "true"
}
with patch("myapp.push_handlers.content_sync_service") as mock_sync:
mock_sync.sync_content.return_value = {"status": "ok", "updated": True}
result = handle_background_data_push(push_data)
mock_sync.sync_content.assert_called_once_with("article-9912")
assert result["action_taken"] == "content_sync"
def test_silent_push_does_not_show_notification():
"""Background data pushes must NEVER call show_notification."""
push_data = {"type": "data_refresh", "user_id": "user-42"}
with patch("myapp.push_handlers.notification_display") as mock_display:
with patch("myapp.push_handlers.data_refresh_service"):
handle_background_data_push(push_data)
mock_display.show.assert_not_called()
def test_background_fetch_respects_fetch_interval():
"""Background fetch should not run more often than the configured interval."""
from myapp.background_fetch import BackgroundFetchManager
manager = BackgroundFetchManager(min_interval_seconds=900) # 15 minutes
with patch("myapp.background_fetch.time.time", return_value=1000):
manager.record_fetch_completed()
with patch("myapp.background_fetch.time.time", return_value=1500): # 8 min later
should_fetch = manager.should_fetch_now()
assert should_fetch is False # Too soon
with patch("myapp.background_fetch.time.time", return_value=2000): # 16 min later
should_fetch = manager.should_fetch_now()
assert should_fetch is TrueTesting Notification Deep Links
When a user taps a push notification, they expect to land on the right screen. Deep link routing is almost never tested and frequently broken by refactors.
# test_push_deep_links.py
import pytest
from unittest.mock import patch, MagicMock
from myapp.push_service import build_notification_with_deep_link
from myapp.deep_link_router import PushDeepLinkRouter
def test_order_notification_includes_correct_deep_link():
message = build_notification_with_deep_link(
token="device-token",
title="Order shipped",
body="Track your order",
deep_link_type="order",
deep_link_id="ORD-4521"
)
assert message.data["deep_link"] == "myapp://orders/ORD-4521"
assert message.data["screen"] == "order_detail"
def test_chat_notification_deep_link_includes_channel_id():
message = build_notification_with_deep_link(
token="device-token",
title="New message",
body="Sarah: Are you there?",
deep_link_type="chat",
deep_link_id="channel-789"
)
assert message.data["deep_link"] == "myapp://chat/channel-789"
assert message.data["screen"] == "chat_room"
def test_router_handles_unknown_deep_link_gracefully():
router = PushDeepLinkRouter()
with pytest.raises(ValueError, match="Unknown deep link type"):
router.resolve(deep_link_type="unknown_type", deep_link_id="abc")
def test_deep_link_url_is_validated_before_send():
"""Malformed deep link URLs should be caught before the notification is sent."""
with pytest.raises(ValueError, match="Invalid deep link URL"):
build_notification_with_deep_link(
token="device-token",
title="Test",
body="Test",
deep_link_type="custom",
deep_link_id="../../../etc/passwd" # path traversal attempt
)Testing Badge Count Updates
Badge count bugs are subtle: they accumulate incorrectly, never clear, or show stale counts after the user reads notifications in-app.
# test_badge_counts.py
from unittest.mock import patch, MagicMock
import pytest
from myapp.push_service import APNSPushService
from myapp.badge_manager import BadgeCountManager
@pytest.fixture
def badge_manager():
return BadgeCountManager(redis_client=MagicMock())
def test_badge_increments_on_new_notification(badge_manager):
badge_manager.redis.get.return_value = b"3"
new_count = badge_manager.increment("user-42")
assert new_count == 4
badge_manager.redis.set.assert_called_once_with("badge:user-42", 4, ex=86400)
def test_badge_resets_to_zero_on_app_open(badge_manager):
badge_manager.redis.get.return_value = b"7"
badge_manager.clear("user-42")
badge_manager.redis.set.assert_called_once_with("badge:user-42", 0, ex=86400)
def test_apns_notification_includes_badge_count():
service = APNSPushService(sandbox=True)
with patch.object(service.badge_manager, "get", return_value=5):
with patch.object(service.client, "send") as mock_send:
mock_send.return_value = MagicMock(status_code=200)
service.send_to_user(
user_id="user-99",
device_token="a" * 64,
title="You have new messages",
body="5 unread"
)
payload = mock_send.call_args[0][1]
assert payload["aps"]["badge"] == 5
def test_badge_never_goes_negative(badge_manager):
badge_manager.redis.get.return_value = b"0"
new_count = badge_manager.decrement("user-42")
assert new_count == 0 # Floor at zeroTesting Notification Grouping and Threading
iOS and Android both support notification grouping (threads on iOS, notification channels on Android):
# test_notification_grouping.py
from myapp.push_service import build_grouped_notification
def test_ios_notification_uses_thread_identifier():
"""iOS groups notifications by thread-identifier in the aps payload."""
message = build_grouped_notification(
platform="ios",
token="a" * 64,
title="Message from Sarah",
body="Are you free tonight?",
group_id="chat-thread-sarah-42"
)
assert message.apns.payload.aps.thread_id == "chat-thread-sarah-42"
def test_android_notification_uses_notification_channel():
"""Android groups notifications by notification channel."""
message = build_grouped_notification(
platform="android",
token="b" * 64,
title="Price drop alert",
body="Item in your wishlist dropped by 30%",
group_id="wishlist-alerts"
)
assert message.android.notification.channel_id == "wishlist-alerts"
assert message.android.notification.notification_count is not None
def test_web_push_notification_tag_enables_replacement():
"""Web Push uses 'tag' to replace existing notifications in the same group."""
from myapp.web_push_service import build_web_push_payload
payload = build_web_push_payload(
title="3 new messages",
body="Sarah, Alex, and Mike sent you messages",
group_tag="inbox-messages",
replace_existing=True
)
assert payload["tag"] == "inbox-messages"
assert payload.get("renotify") is TrueTesting Notification Analytics Pipeline
# test_notification_analytics.py
from unittest.mock import patch, MagicMock, call
import pytest
from datetime import datetime, timedelta
from myapp.analytics.notification_pipeline import NotificationEventPipeline
@pytest.fixture
def pipeline():
return NotificationEventPipeline(
storage=MagicMock(),
event_bus=MagicMock()
)
def test_delivered_event_emitted_on_receipt(pipeline):
pipeline.record_delivered(
notification_id="notif-abc",
device_token="tok-123",
delivered_at=datetime(2026, 5, 18, 10, 0, 0)
)
pipeline.event_bus.emit.assert_called_once_with(
"notification.delivered",
{
"notification_id": "notif-abc",
"device_token": "tok-123",
"delivered_at": "2026-05-18T10:00:00",
}
)
def test_open_rate_computed_correctly(pipeline):
pipeline.storage.get_stats.return_value = {
"sent": 10000,
"delivered": 9200,
"opened": 1380,
"converted": 276,
}
report = pipeline.compute_rates("campaign-may-18")
assert report["delivery_rate"] == pytest.approx(92.0)
assert report["open_rate"] == pytest.approx(15.0) # 1380/9200
assert report["conversion_rate"] == pytest.approx(20.0) # 276/1380
def test_open_rate_handles_zero_delivered(pipeline):
"""Avoid ZeroDivisionError when delivery completely failed."""
pipeline.storage.get_stats.return_value = {
"sent": 1000, "delivered": 0, "opened": 0, "converted": 0
}
report = pipeline.compute_rates("campaign-zero-delivery")
assert report["delivery_rate"] == 0.0
assert report["open_rate"] == 0.0
assert report["conversion_rate"] == 0.0
def test_notification_event_ordering(pipeline):
"""Delivered must always precede opened in the event stream."""
delivered_at = datetime(2026, 5, 18, 10, 0, 0)
opened_at = datetime(2026, 5, 18, 10, 0, 30) # 30 seconds later
pipeline.record_delivered("notif-1", "tok-1", delivered_at)
pipeline.record_opened("notif-1", "tok-1", opened_at)
events = pipeline.event_bus.emit.call_args_list
assert events[0][0][0] == "notification.delivered"
assert events[1][0][0] == "notification.opened"
# An opened event before delivered should raise
with pytest.raises(ValueError, match="Cannot record open before delivery"):
pipeline.record_opened("notif-2", "tok-2", delivered_at)
pipeline.record_delivered("notif-2", "tok-2", opened_at)Cross-Platform Testing Strategy
Structure your test suite to run platform-specific tests in isolation while sharing common assertion helpers:
# tests/push/conftest.py
import pytest
@pytest.fixture(params=["fcm", "apns", "web_push"])
def push_platform(request):
return request.param
@pytest.fixture
def assert_notification_delivered():
"""Shared assertion helper across all platforms."""
def _assert(result):
assert result is not None
assert result.get("success") is True
assert result.get("notification_id") is not None
return _assert
# tests/push/test_cross_platform.py
def test_all_platforms_store_notification_id(push_platform, assert_notification_delivered, mocker):
from myapp.push_factory import create_push_service
service = create_push_service(platform=push_platform)
mocker.patch.object(service, "_send_raw", return_value="platform-notif-id-001")
result = service.send(
target="test-device-or-token",
title="Cross-platform test",
body="This notification was sent by the test suite"
)
assert_notification_delivered(result)
assert service.receipt_store.exists("platform-notif-id-001")Running this complete test suite on every pull request gives you regression protection across the entire push notification surface: payload construction, delivery, analytics, deep links, badge counts, and grouping. The result is a push notification system you can refactor confidently, knowing the tests will catch any regression before it silently stops delivering to your users.