Engineering practice

Validate iOS Push Notifications with simctl on a Cloud Mac

Validate iOS Push Notifications with simctl on a Cloud Mac

A remote pipeline may already be producing an installable iOS app even when the team has no continuously connected test device. In that situation, the easiest issues to miss are not compilation errors, but interactions among notification permissions, foreground presentation, deep-link parameters, and the Notification Service Extension. simctl push injects an APNs-formatted payload directly into iOS Simulator, making it well suited to fast, repeatable pre-submission checks on a cloud Mac.

It cannot replace delivery tests through the real APNs infrastructure, but it can catch many client-side problems during the build stage. The key is to preserve the app state, screenshots, and unified logs rather than relying on the command’s exit status alone.

Define What the Test Can Prove

simctl push validates how a built app handles a notification payload. It can cover notification content parsing, foreground and background delegate callbacks, category actions, deep-link routing, and the output of a Notification Service Extension.

It cannot validate server-side authentication, device token validity, delivery over an external network, APNs rate limits, or power-saving behavior on physical devices. A payload arriving in the simulator also does not prove that the production push pipeline works end to end.

Treat a successful command as confirmation that local injection completed, not that a user received the notification. Base acceptance on the UI, app logs, and routing outcome.

Before starting, confirm that the app declares the notification capability and completes authorization along the path under test. simctl push does not click the system permission prompt on the app’s behalf. UI tests can handle the initial authorization flow, or permission can be granted in advance on a dedicated test simulator, but the test must not depend on the state of a long-lived simulator.

Pin the Simulator and App Inputs

Do not use booted directly in parallel jobs. If several simulators start at the same time, the payload could be sent to a simulator owned by another job. Record a specific UDID for every job, and pass the app path, Bundle ID, and artifact directory as inputs.

set -euo pipefail

: "${SIMULATOR_UDID:?Set SIMULATOR_UDID}"
: "${APP_PATH:?Set APP_PATH}"
: "${BUNDLE_ID:?Set BUNDLE_ID}"

ARTIFACTS="${ARTIFACTS:-$PWD/.ci/push-check}"
mkdir -p "$ARTIFACTS"

xcrun simctl bootstatus "$SIMULATOR_UDID" -b
xcrun simctl install "$SIMULATOR_UDID" "$APP_PATH"
xcrun simctl launch "$SIMULATOR_UDID" "$BUNDLE_ID"
xcrun simctl get_app_container "$SIMULATOR_UDID" "$BUNDLE_ID"

bootstatus -b waits for the system to finish booting, which is more reliable than sleeping for a fixed number of seconds after startup. get_app_container provides a low-cost installation check: if the Bundle ID is wrong or the app is not installed, the job fails before sending the notification.

When retesting the initial authorization flow, create or erase a dedicated simulator for that job instead of indiscriminately resetting all privacy settings on a shared device. This prevents parallel tests from changing one another’s camera, photo, or notification permissions.

Build and Validate the APNs Payload

Store the payload in a JSON file. Include a searchable test identifier so screenshots and app logs can be correlated with the corresponding pipeline job.

PAYLOAD="$ARTIFACTS/foreground.apns"
TRACE_ID="push-check-${CI_RUN_ID:-local}"

cat > "$PAYLOAD" <<JSON
{
  "Simulator Target Bundle": "$BUNDLE_ID",
  "aps": {
    "alert": {
      "title": "Build completed",
      "body": "Open the result for details"
    },
    "sound": "default",
    "badge": 1,
    "category": "BUILD_RESULT"
  },
  "route": "build/result",
  "trace_id": "$TRACE_ID"
}
JSON

plutil -lint "$PAYLOAD"
PAYLOAD_BYTES="$(wc -c < "$PAYLOAD" | tr -d ' ')"
test "$PAYLOAD_BYTES" -le 4096
xcrun simctl push "$SIMULATOR_UDID" "$BUNDLE_ID" "$PAYLOAD"

The 4KB check catches abnormal payloads early, but the simulator does not fully reproduce every server-side validation. The app must still type-check route, identifiers, and custom fields. Do not force-cast values simply because the test payload is trusted.

If the app uses a Notification Service Extension, add "mutable-content": 1 and have the extension write structured entries to the unified log. Its processing time and possible termination by the system must still be handled as failure paths. Never assume the extension will always finish a download or payload rewrite.

Build an Acceptance Matrix for Each App State

Test the same payload in at least three states: foreground, background, and not running. Foreground presentation is usually controlled by a delegate method, so the absence of a banner may be intentional product behavior or a missing callback. Define the expected result for every state.

Scenario Action Required checks
Foreground Inject after launching the app Delegate callback, in-app indication, duplicate events
Background Inject after returning to the Home Screen Banner content, badge, tap routing
Not running Inject after terminating the app Cold-start entry point, parameter parsing, destination screen
Content extension Inject a payload with mutable-content Extension logs, rewritten content, timeout fallback
Invalid fields Omit the route or provide a field with the wrong type Safe fallback, no crash, diagnostic logs

Capture a screenshot after sending the payload so the pipeline does not retain only an empty “command succeeded” conclusion.

xcrun simctl io "$SIMULATOR_UDID" screenshot \
  "$ARTIFACTS/notification.png"

xcrun simctl spawn "$SIMULATOR_UDID" log show \
  --last 2m \
  --style compact \
  --predicate "process == '$BUNDLE_ID'" \
  > "$ARTIFACTS/application.log"

The process name does not necessarily match the Bundle ID. A more reliable approach is to use a fixed logging subsystem in the app and extension, then query by subsystem. Log only the test identifier, state, and error type. Do not record the complete notification body, tokens, or user data.

Encode Failure Conditions in the Pipeline

A reliable notification validation job should do more than execute shell commands. It must also verify that artifacts exist, the app did not crash, and the expected route was recorded. In test builds, the app can write the processed trace_id and result to a dedicated file. The pipeline can then locate the container with simctl get_app_container and read that file. Enable this test interface only in internal build configurations, and disable it in release configurations.

Common false results include reusing a simulator that already has permission and therefore skipping the initial prompt; using booted and crossing streams between parallel jobs; treating the absence of a foreground banner as an injection failure; checking only the screenshot without validating the route after a tap; and querying too broad a log window, which can include entries from the previous job.

Before submission, complete the checks in this order:

  1. Pin Xcode, the runtime, the device model, and the simulator UDID.
  2. Install the artifact from the current build without reusing an old app container.
  3. Validate the JSON syntax, byte count, and required fields.
  4. Run the foreground, background, not-running, and invalid-payload cases separately.
  5. Archive the payload, screenshots, app logs, and unique test identifier.
  6. Report simulator validation separately from real end-to-end APNs testing.

After these steps, client-side push notification behavior is no longer a feature that depends on manual observation. It becomes a repeatable engineering check that preserves evidence and identifies the exact stage at which a failure occurred.

Frequently asked questions

Does a successful simctl push prove that real APNs delivery works?

No. It proves only that the payload was injected into the Simulator. Server authentication, device tokens, network delivery, and APNs limits still require a controlled real-device test.

Why is no banner shown when simctl push exits successfully?

Confirm notification permission, inspect the aps dictionary, and check the app state. A foreground app must explicitly request banner presentation through its notification delegate.

Should CI target booted or a specific Simulator UDID?

Use the UDID assigned to the job. In parallel runs, booted can resolve to the wrong Simulator and mix payloads, screenshots, and logs across jobs.

Dedicated physical Mac mini

Choose a cloud Mac for your next development pipeline

Choose from three fixed configurations and four Asian nodes, then select a rental period based on your actual workload. Each rental includes one dedicated physical machine, not a virtual machine.

Choose a configuration and rent