OneSignal Push Notification Testing: API Testing, Segment Targeting, and A/B Test Notifications

OneSignal Push Notification Testing: API Testing, Segment Targeting, and A/B Test Notifications

OneSignal abstracts the complexity of FCM and APNS behind a unified REST API, but that means your integration is only as reliable as your HTTP layer. This guide covers testing OneSignal notification delivery, segment targeting, A/B test setup, and delivery reporting using Python with requests-mock and httpretty — all without hitting real OneSignal endpoints.

Key Takeaways

OneSignal's REST API distinguishes between player IDs, external user IDs, and segments — test all three targeting modes. Using the wrong targeting parameter silently sends to the wrong audience or returns a 400 error; unit tests catch this before it hits production.

The notification ID in the create response is your tracking handle — always assert it's stored. Your service must persist the OneSignal notification ID returned from the create call to later query delivery status; tests that skip this assertion miss a common integration gap.

A/B test notifications use variants with weight — test that weights sum to 100. OneSignal rejects A/B payloads where variant weights don't sum correctly; a validation test prevents silent failures in your notification pipeline.

Delivery reporting requires a separate GET call — test the polling logic. The notifications/{id} endpoint returns successful, failed, errored, converted counts; your analytics pipeline should poll this and your tests must cover the polling logic.

requests-mock is preferable over mocking the requests library directly. requests-mock provides URL-pattern matching, request body inspection, and response queueing that makes complex multi-call scenarios (create then query) easy to express in tests.

OneSignal is the most widely used push notification platform for mobile and web, used by tens of thousands of apps. Despite this, most teams test their OneSignal integration with... a manual phone test. This guide replaces that with a robust unit test suite that covers every integration point your server code touches.

The OneSignal API Surface You Need to Test

Your server typically calls three OneSignal API groups:

  1. Notifications API — create and query notifications
  2. Players API — manage device registrations and external user IDs
  3. Apps API — read app-level stats

We'll focus on the Notifications API since that's where business logic lives.

Setting Up the Test Environment

pip install requests requests-mock httpretty pytest pytest-mock

Build a thin OneSignal client your tests can exercise:

# myapp/onesignal_client.py
import requests

ONESIGNAL_API_BASE = "https://onesignal.com/api/v1"

class OneSignalClient:
    def __init__(self, app_id: str, api_key: str):
        self.app_id = app_id
        self.api_key = api_key
        self.session = requests.Session()
        self.session.headers.update({
            "Authorization": f"Basic {api_key}",
            "Content-Type": "application/json",
        })

    def create_notification(self, payload: dict) -> dict:
        payload["app_id"] = self.app_id
        response = self.session.post(
            f"{ONESIGNAL_API_BASE}/notifications",
            json=payload
        )
        response.raise_for_status()
        return response.json()

    def get_notification(self, notification_id: str) -> dict:
        response = self.session.get(
            f"{ONESIGNAL_API_BASE}/notifications/{notification_id}",
            params={"app_id": self.app_id}
        )
        response.raise_for_status()
        return response.json()

    def cancel_notification(self, notification_id: str) -> dict:
        response = self.session.delete(
            f"{ONESIGNAL_API_BASE}/notifications/{notification_id}",
            params={"app_id": self.app_id}
        )
        response.raise_for_status()
        return response.json()

Testing Notification Creation by Player IDs

# test_onesignal_player_targeting.py
import requests_mock as requests_mock_lib
import pytest
import json

from myapp.onesignal_client import OneSignalClient
from myapp.push_service import OneSignalPushService

APP_ID = "test-app-id-1234"
API_KEY = "test-api-key-5678"
NOTIFICATION_URL = "https://onesignal.com/api/v1/notifications"

@pytest.fixture
def client():
    return OneSignalClient(app_id=APP_ID, api_key=API_KEY)

@pytest.fixture
def service():
    return OneSignalPushService(app_id=APP_ID, api_key=API_KEY)

def test_send_to_player_ids_builds_correct_payload(client):
    player_ids = ["player-abc", "player-def", "player-ghi"]

    with requests_mock_lib.Mocker() as m:
        m.post(NOTIFICATION_URL, json={"id": "notif-id-001", "recipients": 3})

        result = client.create_notification({
            "include_player_ids": player_ids,
            "headings": {"en": "Flash Deal"},
            "contents": {"en": "40% off everything — 2 hours only"},
        })

        # Verify what was actually sent to OneSignal
        sent_body = m.last_request.json()
        assert sent_body["app_id"] == APP_ID
        assert sent_body["include_player_ids"] == player_ids
        assert sent_body["headings"]["en"] == "Flash Deal"
        assert result["id"] == "notif-id-001"
        assert result["recipients"] == 3

def test_send_stores_notification_id(service):
    """The returned notification ID must be persisted for delivery tracking."""
    with requests_mock_lib.Mocker() as m:
        m.post(NOTIFICATION_URL, json={"id": "notif-xyz-789", "recipients": 1})

        notification_id = service.send_to_user(
            user_id="user-42",
            title="Your package arrived",
            body="Pick up at locker B3"
        )

        assert notification_id == "notif-xyz-789"
        # Service should have stored this in DB
        assert service.notification_store.get("notif-xyz-789") is not None

Testing Segment-Based Targeting

# test_onesignal_segments.py
import requests_mock as requests_mock_lib
import pytest

from myapp.onesignal_client import OneSignalClient

NOTIFICATION_URL = "https://onesignal.com/api/v1/notifications"

@pytest.fixture
def client():
    return OneSignalClient(app_id="app-id", api_key="api-key")

def test_send_to_segment_uses_included_segments(client):
    with requests_mock_lib.Mocker() as m:
        m.post(NOTIFICATION_URL, json={"id": "notif-segment-001", "recipients": 5420})

        client.create_notification({
            "included_segments": ["Active Users", "Premium"],
            "headings": {"en": "New Feature"},
            "contents": {"en": "Check out our new dashboard"},
        })

        body = m.last_request.json()
        assert "included_segments" in body
        assert "Active Users" in body["included_segments"]
        assert "Premium" in body["included_segments"]
        # Should NOT include player_ids or external_user_ids when using segments
        assert "include_player_ids" not in body
        assert "include_external_user_ids" not in body

def test_send_to_all_users_uses_all_segment(client):
    with requests_mock_lib.Mocker() as m:
        m.post(NOTIFICATION_URL, json={"id": "broadcast-001", "recipients": 120000})

        from myapp.push_service import OneSignalPushService
        service = OneSignalPushService(app_id="app-id", api_key="api-key")
        result = service.broadcast(title="App Update", body="Version 3.0 is live")

        body = m.last_request.json()
        assert body["included_segments"] == ["All"]
        assert result["recipients"] == 120000

def test_excluded_segments_filter_correctly(client):
    with requests_mock_lib.Mocker() as m:
        m.post(NOTIFICATION_URL, json={"id": "targeted-001", "recipients": 3200})

        client.create_notification({
            "included_segments": ["All"],
            "excluded_segments": ["Unsubscribed", "Test Devices"],
            "headings": {"en": "Important Update"},
            "contents": {"en": "Terms of service have changed"},
        })

        body = m.last_request.json()
        assert "Unsubscribed" in body["excluded_segments"]
        assert "Test Devices" in body["excluded_segments"]

Testing A/B Test Notification Setup

OneSignal supports A/B testing via the ab_test_id field and variants in the payload:

# test_onesignal_ab_tests.py
import requests_mock as requests_mock_lib
import pytest

from myapp.push_service import OneSignalPushService

NOTIFICATION_URL = "https://onesignal.com/api/v1/notifications"

@pytest.fixture
def service():
    return OneSignalPushService(app_id="app-123", api_key="key-456")

def test_ab_test_creates_notification_with_variants(service):
    variants = [
        {"contents": {"en": "Your cart is waiting"}, "weight": 50},
        {"contents": {"en": "You left something behind"}, "weight": 50},
    ]

    with requests_mock_lib.Mocker() as m:
        m.post(NOTIFICATION_URL, json={
            "id": "ab-test-notif-001",
            "recipients": 800,
            "ab_test_id": "ab-001"
        })

        result = service.send_ab_test(
            segment="Cart Abandoners",
            headings={"en": "Don't forget!"},
            variants=variants
        )

        body = m.last_request.json()
        assert "ab_test_id" in body or "variants" in body
        assert result["id"] == "ab-test-notif-001"

def test_ab_test_weights_must_sum_to_100(service):
    bad_variants = [
        {"contents": {"en": "Version A"}, "weight": 60},
        {"contents": {"en": "Version B"}, "weight": 60},  # sums to 120
    ]

    with pytest.raises(ValueError, match="weights must sum to 100"):
        service.send_ab_test(
            segment="Active Users",
            headings={"en": "Test"},
            variants=bad_variants
        )

def test_ab_test_requires_at_least_two_variants(service):
    with pytest.raises(ValueError, match="at least 2 variants"):
        service.send_ab_test(
            segment="Active Users",
            headings={"en": "Test"},
            variants=[{"contents": {"en": "Only version"}, "weight": 100}]
        )

Testing Delivery Reporting

# test_onesignal_delivery.py
import requests_mock as requests_mock_lib
import pytest

from myapp.onesignal_client import OneSignalClient
from myapp.analytics_service import NotificationAnalytics

BASE_URL = "https://onesignal.com/api/v1"

@pytest.fixture
def client():
    return OneSignalClient(app_id="app-id", api_key="api-key")

def test_get_notification_returns_delivery_stats(client):
    notif_id = "notif-abc-123"

    with requests_mock_lib.Mocker() as m:
        m.get(
            f"{BASE_URL}/notifications/{notif_id}",
            json={
                "id": notif_id,
                "successful": 4821,
                "failed": 12,
                "errored": 3,
                "converted": 289,
                "remaining": 0,
                "queued_at": 1716019200,
                "send_after": 1716019200,
            }
        )

        stats = client.get_notification(notif_id)

        assert stats["successful"] == 4821
        assert stats["failed"] == 12
        assert stats["converted"] == 289

def test_analytics_calculates_delivery_rate(client):
    notif_id = "notif-xyz-789"
    analytics = NotificationAnalytics(client)

    with requests_mock_lib.Mocker() as m:
        m.get(f"{BASE_URL}/notifications/{notif_id}", json={
            "id": notif_id,
            "successful": 9000,
            "failed": 1000,
            "errored": 0,
            "converted": 450,
            "remaining": 0,
        })

        report = analytics.get_report(notif_id)

        assert report["delivery_rate"] == pytest.approx(90.0)
        assert report["conversion_rate"] == pytest.approx(5.0)
        assert report["total_sent"] == 10000

def test_delivery_polling_retries_while_remaining_gt_zero(client, mocker):
    """Analytics poller should retry until remaining == 0."""
    notif_id = "notif-polling-test"
    analytics = NotificationAnalytics(client)
    mock_sleep = mocker.patch("time.sleep")

    with requests_mock_lib.Mocker() as m:
        # First call: still sending
        m.get(f"{BASE_URL}/notifications/{notif_id}", [
            {"json": {"id": notif_id, "successful": 100, "failed": 0, "remaining": 500}},
            {"json": {"id": notif_id, "successful": 550, "failed": 5, "remaining": 50}},
            {"json": {"id": notif_id, "successful": 590, "failed": 10, "remaining": 0}},
        ])

        report = analytics.poll_until_complete(notif_id, poll_interval=5)

        assert report["successful"] == 590
        assert mock_sleep.call_count == 2  # Slept twice before remaining hit 0

Testing External User ID Targeting

# test_onesignal_external_ids.py
import requests_mock as requests_mock_lib

from myapp.push_service import OneSignalPushService

NOTIFICATION_URL = "https://onesignal.com/api/v1/notifications"

def test_send_to_external_user_id():
    service = OneSignalPushService(app_id="app-id", api_key="api-key")

    with requests_mock_lib.Mocker() as m:
        m.post(NOTIFICATION_URL, json={"id": "notif-ext-001", "recipients": 1})

        service.send_to_external_user(
            external_user_id="user-db-id-12345",
            title="Account Alert",
            body="New login from Chicago, IL"
        )

        body = m.last_request.json()
        assert body["include_external_user_ids"] == ["user-db-id-12345"]
        # Must include channel_for_external_user_ids for proper routing
        assert "channel_for_external_user_ids" in body
        assert body["channel_for_external_user_ids"] == "push"

Using httpretty for Global HTTP Interception

For tests where requests-mock context managers are too verbose, httpretty provides global interception:

# test_onesignal_httpretty.py
import httpretty
import json
import pytest

from myapp.onesignal_client import OneSignalClient

@httpretty.activate
def test_cancel_notification_sends_delete():
    httpretty.register_uri(
        httpretty.DELETE,
        "https://onesignal.com/api/v1/notifications/notif-to-cancel",
        body=json.dumps({"success": True}),
        content_type="application/json"
    )

    client = OneSignalClient(app_id="app-id", api_key="api-key")
    result = client.cancel_notification("notif-to-cancel")

    assert result["success"] is True
    assert httpretty.last_request().method == "DELETE"

With this test suite, you can run pytest tests/onesignal/ -v on every commit and know that your OneSignal integration handles player targeting, segment broadcasting, A/B test configuration, and delivery analytics correctly — before those notifications ever reach a real user's device.

Read more