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 pyjwtCreate 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 responseTesting 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"] == 1Testing 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 == 200Testing 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_URLIntegrating 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.