APNS Testing Guide: iOS Push Notifications with Python, Sandbox Environment, and Payload Validation

APNS Testing Guide: iOS Push Notifications with Python, Sandbox Environment, and Payload Validation

Apple Push Notification Service (APNS) has stricter requirements than FCM: a 4096-byte payload limit, two distinct environments (sandbox and production), and a choice between certificate-based and JWT token-based authentication. This guide walks through testing APNS integrations in Python using httpx-based clients, mocked HTTP/2 connections, and payload size assertions — without requiring a physical iPhone.

Key Takeaways

Always test against the sandbox endpoint in CI. The sandbox endpoint (api.sandbox.push.apple.com) behaves identically to production in terms of validation, making it safe for automated tests without risk of spamming real users.

The 4096-byte payload limit is a hard failure you must test. APNS returns a 400 with reason PayloadTooLarge — your service must validate size before sending or risk silent notification loss at scale.

JWT tokens expire after one hour — test the refresh path. Certificate-based auth avoids expiry, but most modern integrations use JWT; your tests must cover token renewal and the ExpiredProviderToken error code.

Test all three push types separately. Alert pushes (visible notification), background pushes (content-available: 1), and VoIP pushes (apns-push-type: voip) follow different rules and are processed by different iOS subsystems.

Device token format matters more than its content in unit tests. APNS device tokens are 64-hex-character strings; testing with wrong-length or non-hex tokens lets you validate your input sanitization without a real device.

Apple Push Notification Service is one of the most specification-driven APIs you will encounter. Unlike FCM, which is lenient about payload structure, APNS enforces hard limits on payload size, requires HTTP/2, and distinguishes between push types at the protocol level. Writing tests for APNS integration means covering the spec, not just the happy path.

Understanding the APNS Architecture for Testing

APNS uses HTTP/2 connections with either mutual TLS (certificate-based auth) or Bearer token auth (JWT). Your tests need to account for both. The key endpoint variants are:

  • Sandbox: https://api.sandbox.push.apple.com/3/device/{device_token}
  • Production: https://api.push.apple.com/3/device/{device_token}

A valid device token is a 64-character hex string. In tests, use realistic-looking tokens to ensure your validation code exercises properly.

Setting Up the Test Environment

pip install httpx pytest pytest-asyncio cryptography pyjwt

Create a minimal APNS client that can be tested without real credentials:

# myapp/apns_client.py
import json
import time
import httpx
import jwt
from cryptography.hazmat.primitives import serialization

SANDBOX_URL = "https://api.sandbox.push.apple.com"
PRODUCTION_URL = "https://api.push.apple.com"

class APNSClient:
    def __init__(self, team_id, key_id, private_key_pem, bundle_id, sandbox=True):
        self.team_id = team_id
        self.key_id = key_id
        self.private_key_pem = private_key_pem
        self.bundle_id = bundle_id
        self.base_url = SANDBOX_URL if sandbox else PRODUCTION_URL
        self._token = None
        self._token_issued_at = 0

    def _get_auth_token(self):
        now = int(time.time())
        if self._token and (now - self._token_issued_at) < 3000:  # 50 min cache
            return self._token

        payload = {"iss": self.team_id, "iat": now}
        headers = {"alg": "ES256", "kid": self.key_id}
        self._token = jwt.encode(
            payload, self.private_key_pem, algorithm="ES256", headers=headers
        )
        self._token_issued_at = now
        return self._token

    def send(self, device_token, notification_payload, push_type="alert", priority=10):
        if len(device_token) != 64 or not all(c in "0123456789abcdef" for c in device_token.lower()):
            raise ValueError(f"Invalid device token format: {device_token}")

        payload_json = json.dumps(notification_payload)
        if len(payload_json.encode("utf-8")) > 4096:
            raise ValueError(f"Payload exceeds 4096 bytes: {len(payload_json.encode())} bytes")

        token = self._get_auth_token()
        url = f"{self.base_url}/3/device/{device_token}"
        headers = {
            "authorization": f"bearer {token}",
            "apns-topic": self.bundle_id,
            "apns-push-type": push_type,
            "apns-priority": str(priority),
        }

        with httpx.Client(http2=True) as client:
            response = client.post(url, json=notification_payload, headers=headers)

        return response

Testing Payload Validation

Payload validation should be tested before any network call happens:

# test_apns_payload.py
import pytest
from myapp.apns_client import APNSClient

VALID_TOKEN = "a" * 64  # 64 hex chars
FAKE_KEY_PEM = b"fake-key"  # won't be used in validation tests

@pytest.fixture
def client():
    return APNSClient(
        team_id="TEAM123456",
        key_id="KEY123456",
        private_key_pem=FAKE_KEY_PEM,
        bundle_id="com.example.myapp",
        sandbox=True
    )

def test_rejects_token_too_short(client):
    with pytest.raises(ValueError, match="Invalid device token format"):
        client.send("abc123", {"aps": {"alert": "Hi"}})

def test_rejects_non_hex_token(client):
    non_hex = "z" * 64
    with pytest.raises(ValueError, match="Invalid device token format"):
        client.send(non_hex, {"aps": {"alert": "Hi"}})

def test_rejects_payload_over_4096_bytes(client):
    # Build a payload that exceeds the limit
    large_data = {"aps": {"alert": "Hi"}, "custom": "x" * 5000}
    with pytest.raises(ValueError, match="exceeds 4096 bytes"):
        client.send(VALID_TOKEN, large_data)

def test_accepts_payload_at_exactly_4096_bytes(client, mocker):
    mocker.patch("myapp.apns_client.APNSClient._get_auth_token", return_value="mock-jwt")
    mocker.patch("httpx.Client.post", return_value=mocker.MagicMock(status_code=200))

    # Build payload just under the limit
    filler = "x" * (4096 - 50)
    payload = {"aps": {"alert": "Hi"}, "d": filler}

    # Should not raise
    client.send(VALID_TOKEN, payload)

Testing Alert vs Background vs VoIP Push Types

Each push type has different APNS requirements that your code must enforce:

# test_apns_push_types.py
from unittest.mock import patch, MagicMock
import pytest

from myapp.apns_client import APNSClient

VALID_TOKEN = "b" * 64

@pytest.fixture
def client(mocker):
    c = APNSClient(
        team_id="TEAM123",
        key_id="KEY123",
        private_key_pem=b"fake",
        bundle_id="com.example.app",
        sandbox=True
    )
    mocker.patch.object(c, "_get_auth_token", return_value="mock-jwt-token")
    return c

def test_alert_push_sets_correct_headers(client, mocker):
    mock_response = MagicMock(status_code=200, text="")
    mock_post = mocker.patch("httpx.Client.post", return_value=mock_response)

    payload = {"aps": {"alert": {"title": "Reminder", "body": "Meeting in 5 min"}}}
    client.send(VALID_TOKEN, payload, push_type="alert", priority=10)

    _, kwargs = mock_post.call_args
    headers = kwargs.get("headers") or mock_post.call_args[0][1] if len(mock_post.call_args[0]) > 1 else {}
    # Access headers from the actual call
    call_headers = mock_post.call_args.kwargs.get("headers", {})
    assert call_headers.get("apns-push-type") == "alert"
    assert call_headers.get("apns-priority") == "10"

def test_background_push_uses_content_available(client, mocker):
    mock_response = MagicMock(status_code=200, text="")
    mocker.patch("httpx.Client.post", return_value=mock_response)

    # Background pushes must have content-available: 1 and NO alert
    payload = {"aps": {"content-available": 1}}
    client.send(VALID_TOKEN, payload, push_type="background", priority=5)

    # Verify payload structure is correct for background push
    assert payload["aps"].get("content-available") == 1
    assert "alert" not in payload["aps"]

def test_background_push_must_use_priority_5():
    """Background pushes with priority 10 are silently rejected by APNS."""
    from myapp.notification_builder import build_background_push
    payload, options = build_background_push(data_key="sync", data_value="true")

    assert options["priority"] == 5
    assert options["push_type"] == "background"
    assert payload["aps"]["content-available"] == 1

Testing JWT Token Generation and Expiry

JWT token renewal is a common source of production bugs:

# test_apns_jwt.py
import time
from unittest.mock import patch
import pytest
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization

from myapp.apns_client import APNSClient

@pytest.fixture
def real_ec_key():
    """Generate a real EC key for JWT signing tests."""
    private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
    return private_key.private_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PrivateFormat.PKCS8,
        encryption_algorithm=serialization.NoEncryption()
    )

def test_jwt_token_is_cached_for_50_minutes(real_ec_key):
    client = APNSClient(
        team_id="TEAM123456",
        key_id="KEYABC123",
        private_key_pem=real_ec_key,
        bundle_id="com.test.app",
        sandbox=True
    )

    token_1 = client._get_auth_token()
    token_2 = client._get_auth_token()

    assert token_1 == token_2  # Same token returned from cache

def test_jwt_token_refreshes_after_50_minutes(real_ec_key):
    client = APNSClient(
        team_id="TEAM123456",
        key_id="KEYABC123",
        private_key_pem=real_ec_key,
        bundle_id="com.test.app",
        sandbox=True
    )

    token_1 = client._get_auth_token()

    # Simulate token issued 51 minutes ago
    client._token_issued_at = int(time.time()) - 3060

    token_2 = client._get_auth_token()

    assert token_1 != token_2  # New token issued

def test_handles_expired_provider_token_error(mocker):
    """When APNS returns ExpiredProviderToken, client must refresh and retry."""
    from myapp.push_service import APNSPushService

    service = APNSPushService(sandbox=True)
    mocker.patch.object(service.client, "_get_auth_token", return_value="old-token")

    expired_response = MagicMock(
        status_code=403,
        json=lambda: {"reason": "ExpiredProviderToken"}
    )
    success_response = MagicMock(status_code=200, text="")

    mock_send = mocker.patch.object(
        service.client, "send",
        side_effect=[expired_response, success_response]
    )

    result = service.send_with_retry("a" * 64, {"aps": {"alert": "Hi"}})

    assert mock_send.call_count == 2
    assert result.status_code == 200

Testing Sandbox vs Production Endpoint Selection

Accidentally sending to production in tests (or to sandbox in production) is a real risk:

# test_apns_environment.py
from myapp.apns_client import APNSClient, SANDBOX_URL, PRODUCTION_URL

def test_client_uses_sandbox_by_default():
    client = APNSClient(
        team_id="T", key_id="K", private_key_pem=b"k",
        bundle_id="com.test", sandbox=True
    )
    assert client.base_url == SANDBOX_URL
    assert "sandbox" in client.base_url

def test_client_uses_production_when_specified():
    client = APNSClient(
        team_id="T", key_id="K", private_key_pem=b"k",
        bundle_id="com.test", sandbox=False
    )
    assert client.base_url == PRODUCTION_URL
    assert "sandbox" not in client.base_url

def test_environment_derived_from_app_config(mocker):
    """The sandbox flag should come from app config, never be hardcoded."""
    from myapp.config import AppConfig

    mocker.patch.object(AppConfig, "get", side_effect=lambda k: {
        "APNS_SANDBOX": "false",
        "APNS_BUNDLE_ID": "com.example.prod"
    }.get(k))

    from myapp.push_factory import create_apns_client
    client = create_apns_client()

    assert client.base_url == PRODUCTION_URL

Integrating with CI

Add a pytest marker to separate APNS tests that require network from pure unit tests:

# conftest.py
import pytest

def pytest_addoption(parser):
    parser.addoption("--apns-integration", action="store_true",
                     help="Run APNS integration tests against sandbox")

def pytest_collection_modifyitems(config, items):
    if not config.getoption("--apns-integration"):
        skip = pytest.mark.skip(reason="Need --apns-integration flag")
        for item in items:
            if "apns_integration" in item.keywords:
                item.add_marker(skip)

Run unit tests on every commit: pytest tests/apns/ and run integration tests only when you have real credentials available: pytest tests/apns/ --apns-integration. This gives you fast CI without sacrificing coverage of the specification-level behaviors that APNS enforces.

Read more