Push Notification Testing Best Practices: End-to-End Delivery, Silent Push, Background Fetch, and Analytics

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 yet

APNS 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 removed

Testing 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 True

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 zero

Testing 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 True

Testing 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.

Read more