Blog / How to Test Push Notifications on iOS and Android
How-To

How to Test Push Notifications on iOS and Android

Push testing carries one piece of folklore, that you need a real device. Two platform changes made it false. Here is what actually runs on a simulator and in CI.

Since Xcode 14, an iOS Simulator running iOS 16 on an Apple silicon Mac generates its own APNs registration token and receives real remote pushes from Apple’s sandbox servers. An Android emulator built on a Google Play system image registers with Firebase Cloud Messaging the same way a handset does. Neither fact is new, and neither shows up in most of what gets written about testing push.

So the folklore holds. You need a real device, a real backend, and a real round trip through Apple or Google, and a CI runner has none of those, so the notification flow ships on manual spot checks and the regressions land in production, where the user who tapped “Your order shipped” gets a blank screen.

Push notification testing is not one test that needs a device. It splits into three independent layers, and the two that carry almost every regression never touch the push network at all. Here is the exact tooling for each layer, and the narrow slice that genuinely still wants a physical handset.

What you’ll learn

  • Why the real-device rule stopped being true, and when
  • The three layers of push testing, and what each one costs to run
  • Exact simctl and adb commands for iOS and Android
  • How to test the permission prompt and tap-through routing
  • What still needs a physical handset, and what does not

Why Push Notification Tests Get Skipped

Push notification testing breaks at the seam between three systems, each owned by someone else. The OS controls the permission model, your app owns the notification-handling code, and Apple or Google runs the delivery network. Most suites treat all three as one untestable blob, price the whole thing at the cost of the hardest part, and skip it.

The hard part is real. A remote push travels from your server through the Apple Push Notification service or Firebase Cloud Messaging to a registration token, and a plain CI runner has no token and nothing signed to attach one to. What does not follow is the conclusion teams draw from it. Because the transport is awkward, the permission prompt and the display-and-tap behavior get written off alongside it, and those two need no network at all.

The cost of skipping lands on the flow you can least afford to lose. A notification is the front door to re-engagement, and on modern Android it is not even guaranteed to appear. Android 13 made notifications opt-in with the POST_NOTIFICATIONS runtime permission, so an untested permission path silently suppresses every push you send. Push joins the same class of gap as mobile E2E flows that flake, where the code runs in a context manual QA rarely reproduces, so it rots quietly.

Three Layers of Push Notification Testing

Separating the layers is what makes push tractable. Each one verifies a different system, fails for different reasons, and costs a different amount to run. Conflate them and you test nothing. Split them and two of the three become ordinary CI tests you gate on every commit.

LayerWhat it verifiesWhat it takes to runWhere it belongs
PermissionThe app requests, handles, and respects notification permission in granted and denied statesadb on Android, a reinstall on iOS. No networkEvery commit
Display and routingA notification renders correctly and tapping it opens the right screen with the right statesimctl push on iOS, adb on Android. No networkEvery commit
Real transportA payload travels from your backend through APNs or FCM to a registered clientReal credentials, a live registration token, and a Play-services emulator or an Apple silicon MacNightly

The two top rows are where almost every regression lives, in a permission prompt that never fires, a deep link that routes to the wrong screen, or a payload your client cannot parse. The bottom row breaks least often, because most of it is Apple’s and Google’s code rather than yours. Run it on a cadence and spend the per-commit budget on the layers you actually own.

How to Test Push Notifications on iOS

There are two ways into an iOS Simulator, and they cover different layers. One never touches the network. The other is a genuine APNs round trip that most guides still describe as impossible.

The first is a locally simulated push. Since Xcode 11.4, simctl takes a payload file and delivers it to a booted simulator through the real UNUserNotificationCenter path, so your app’s notification-handling code runs unchanged. Write the payload as a .apns JSON file. Put a top-level Simulator Target Bundle key in the file, pass the bundle identifier on the command line, or do both. Apple’s release notes only make the command-line argument optional when that key is present.

{
  "Simulator Target Bundle": "com.yourcompany.app",
  "aps": {
    "alert": {
      "title": "Your order shipped",
      "body": "Tap to track it"
    },
    "badge": 1,
    "sound": "default"
  },
  "deeplink": "yourapp://orders/8675309"
}

You can also drag that file straight onto the simulator window, which is the local workflow Antoine van der Lee documents and the reason the bundle key exists. CI wants the command instead. Boot the simulator, install the build under test, and deliver the payload:

xcrun simctl push booted com.yourcompany.app order_shipped.apns

Then assert the banner, the payload handling, and the routing. One caveat before you write the badge assertion. Sarun Wongpatcharapakorn found the badge key did not apply in his simulator testing, so treat badge count as a device assertion until you have watched it work on your own setup.

The second way is a real remote push, and this is where the folklore has gone stale. Xcode 14 added remote notification support to the Simulator. A simulator running iOS 16 on macOS 13 or later, on a Mac with Apple silicon or a T2 chip, generates its own registration token and receives pushes your provider server sends to the APNs sandbox at api.sandbox.push.apple.com. Your backend sends, the simulator receives, and no handset sits in the loop.

Two things to know before you build a pipeline on it. A simulator-generated token is not shaped like a device token, so nothing downstream should assume a fixed length. And the endpoint is the sandbox rather than production, so a production APNs round trip is still hardware territory.

How to Test Push Notifications on Android

Android splits the same way, with one trap in the tooling that costs people an afternoon. The shell will post a notification for you, but not as your app.

For the display-and-routing layer you can post a notification straight from the shell. cmd notification post has been in AOSP since Android 10 (API 29):

adb shell cmd notification post -S bigtext \
  -t 'Your order shipped' 'orderTag' 'Tap to track it'

Read the caveat before you build on that. The notification is posted by com.android.shell, not by your app, because the command resolves the calling package from the shell UID. The command is a fine way to exercise tray behavior and an intent you wire yourself. What it will not do is run your app’s own notification-building code. To cover that path, trigger it from an instrumentation test or a debug entry point inside the app, and keep the shell command for the parts that genuinely are OS behavior.

For real transport, an emulator is enough, which is the second place the real-device rule falls apart. Firebase’s client requirements say FCM needs a device with the Play Store installed or an emulator running Google APIs. A bare AOSP image will never register, and that one configuration detail sits behind a lot of the advice telling you to go buy phones.

Build the AVD on a Google APIs or Google Play system image. It registers and receives like a handset. Send through the FCM HTTP v1 API against the token it hands you.

One more correction worth making before you write assertions. onMessageReceived is not the tap handler. It fires on receipt, for data messages in the foreground and background and for notification messages only while your app is in the foreground. Two exceptions on the data side: a force-stopped app receives nothing until the user opens it again, and a normal-priority message can be held until the device leaves Doze. A notification that arrives in the background goes to the tray, and the tap launches your activity with the payload in the launch intent, which you read from getIntent().getExtras(). Assert against the intent path, not the callback, or the background case quietly asserts nothing.

How to Test the Notification Permission Flow

Force the app into each permission state and assert it behaves in all of them, especially denied. Android 13 (API level 33) made notifications opt-in, so a user who dismisses the prompt receives nothing and the re-engagement strategy dies without a single error in the logs. An app that never tests the denied path will not know.

POST_NOTIFICATIONS is an ordinary runtime permission, so adb drives it directly. Set the state before launch and run the test in each one:

# A user who denied notifications
adb shell pm revoke com.yourcompany.app android.permission.POST_NOTIFICATIONS

# A user who granted them
adb shell pm grant com.yourcompany.app android.permission.POST_NOTIFICATIONS

# A user who has never been asked, so the prompt appears again.
# Revoke alone leaves the user-set and user-fixed flags behind and the dialog stays suppressed.
adb shell pm clear-permission-flags com.yourcompany.app android.permission.POST_NOTIFICATIONS user-set user-fixed

iOS gives you no equivalent switch. Authorization is requested through UNUserNotificationCenter, the system stores the answer on first response, and every later call returns that stored answer without showing a dialog. simctl privacy carries no notifications service either, so the only clean reset is a reinstall:

xcrun simctl uninstall booted com.yourcompany.app

Notifications are one permission among several and the mechanics generalize. The same reset-then-assert discipline applied to camera and location permissions is what keeps a permission suite deterministic instead of order-dependent.

Denial Is the Default State Now

The granted path is the one everyone checks. On Android 13 and later a user who ignores the prompt counts as denied, so the fallback UI, the re-prompt, and the Settings path each need their own test case.

How to Test Notification Tap-Through and Routing

Deliver a notification carrying a deep link, tap it, and assert the app opened the correct screen with the correct state. Tap-through breaks more often than the other two layers, because the routing code runs only when a notification is tapped from a cold or backgrounded app, a path manual QA almost never repeats the same way twice. A shipped-order notification that lands the user on a blank screen fails right here.

The shape is identical across platforms. Deliver with simctl push on iOS or your posted notification on Android, then drive the tap and assert the destination:

  1. Point the payload at something specific: send a deep link like yourapp://orders/8675309 rather than a bare launch.
  2. Tap from all three entry states: lock screen, notification shade, and cold start. Each is a different code path, and only one of them is the one you checked by hand.
  3. Assert the destination, not the transition: confirm the app landed on the order detail screen and loaded order 8675309, not that some screen opened.
  4. Run the broken case: an expired or invalid deep link should degrade gracefully rather than crash.

Tap-through is where push notifications meet end-to-end testing. The notification is only the trigger, and the real assertion sits several screens deep, on UI that gets redesigned on its own schedule. Selector-based scripts are brittle exactly there, which is the usual maintenance tax on mobile test automation.

Automate the Layers You Own

Point Pie at the screen behind the tap. It holds through redesigns.

Book a Demo

What to Run in CI, and What Still Needs a Real Device

Gate the two network-free layers on every commit and put the rest on a cadence. Permission and tap-through tests need no store signing and no live token, so they run on standard hosted runners next to your existing UI suite.

Reset state between cases, a reinstall on iOS and pm clear-permission-flags on Android, because state that leaks between tests reintroduces exactly the flakiness you just removed. Treat a runner with no booted simulator as a setup failure rather than a test failure, so a misconfigured agent never reads as a product bug.

The transport layer is where the hardware question actually lives. Three constraints decide it, and not one of them is a phone.

  • The Mac matters more than the phone: the APNs-to-Simulator path needs macOS 13 or later on Apple silicon or a T2 chip. An Intel macOS runner or a Linux runner cannot do it, which makes this a fleet decision rather than a device-farm decision.
  • The Android image matters more than the hardware: an emulator on a Google APIs or Play system image registers with FCM. A bare AOSP image never will, no matter how many times you retry the token fetch.
  • Production is still device territory: the Simulator reaches the APNs sandbox. Proving that a production certificate, a production key, and a production payload survive the round trip runs on real hardware, nightly, so an expired certificate surfaces within a day instead of during a campaign.

Everything else in the push flow, which is to say every part your own code owns, belongs in the per-commit suite. The same split governs the rest of mobile app testing, where the expensive environment gets a schedule and the code you wrote gets a gate.

How Pie Tests Notification-Driven Flows

We hit this wall building Pie’s mobile agent. Getting a payload onto a virtual device is the awkward part, and it is not the part that breaks your app. Pie runs on the iOS Simulator and the Android emulator and reads the screen rather than a view hierarchy, which is what decides where the value of a notification test actually sits.

The banner is the cheap assertion. Everything after the tap is the expensive one, and it is the part a selector-based script cannot hold through a redesign, so the coverage starts one step past the notification.

  • Scripts put the app in the state the notification announces: an execution script runs before, during, or after a test case and can call your own API, so the order is already shipped by the time the assertions start.
  • The destination is reached the way a user reaches it: deep-link entry into the screen the notification points at, rather than a scripted walk through the app.
  • The assertion is visual: the agent checks the resulting screen by what it looks like, so a redesign of the order detail page does not snap every selector downstream of the tap.

What Pie does not own is the transport. Delivery through APNs or FCM depends on your provider credentials and stays on the schedule described above. The layer worth automating hardest is the one that breaks, and that is everything the tap leads to. Pie runs that flow across iOS and Android from one definition, with self-healing tests absorbing the redesigns.

Price the Layers Separately

Push notifications go untested because teams price the whole flow at the cost of its hardest layer, and the hardest layer moved. Locally simulated pushes have worked since 2020, real APNs pushes to a Simulator since 2022, and an emulator on a Play services image has always been able to register with FCM. The rule everyone repeats is four years out of date.

Automate permission and tap-through on every commit with simctl and adb. Put the production round trip on a nightly. Stop paying handset prices for code that runs fine on a laptop.

The notification is the front door to the flows you care about most. An autonomous QA platform that checks what happens after the tap gives that front door the same coverage as everything else you ship.

Cover the Flow Behind the Tap

Send Pie past the banner. The screen underneath gets tested too.

Book a Demo

Frequently Asked Questions

Yes, and there are two separate ways. Since Xcode 11.4 the Simulator accepts a locally simulated push through xcrun simctl push booted <bundle-id> payload.apns, which covers display, permission handling, and tap routing. Since Xcode 14, a Simulator running iOS 16 on macOS 13 or later, on a Mac with Apple silicon or a T2 chip, also generates its own registration token and receives real remote pushes from the APNs sandbox.

Mostly no, which surprises people. Permission handling, notification display, and tap routing all run on a simulator or emulator with no network at all.

Real transport also runs virtually, through the APNs sandbox to a Simulator on an Apple silicon Mac, or through Firebase Cloud Messaging to an emulator built on a Google Play system image. What stays on hardware is the production APNs environment and any behavior specific to an OEM Android skin.

Android 13 (API level 33) introduced the POST_NOTIFICATIONS runtime permission, so notifications are off by default until a user opts in. In a test you set the grant state with adb, using pm grant <package> android.permission.POST_NOTIFICATIONS to allow and pm revoke to simulate a user who declined. Then assert the app behaves correctly in both states rather than assuming permission is always granted.
Put a deep link in the payload, deliver the notification, tap it, and assert the app opened the correct screen with the correct state. Run the tap from the lock screen, the notification shade, and a cold start, because each is a different code path. On Android, remember that a background notification tap arrives through the launch intent rather than onMessageReceived, so assert against the getIntent extras.
Yes. Locally simulated pushes on the iOS Simulator and adb-driven notifications on the Android emulator need no network and no store signing, so they run on standard hosted runners. The real transport path is fussier about the runner than about hardware. APNs to a Simulator needs macOS 13 on Apple silicon or a T2 chip, and Firebase Cloud Messaging needs an emulator image that carries Google Play services.
On iOS, notification authorization is stored once per install and simctl privacy carries no notifications service, so the clean reset is to uninstall and reinstall with xcrun simctl uninstall booted <bundle-id>. On Android 13 and later, pm revoke alone will not bring the prompt back, because the user-set and user-fixed flags survive it and the dialog stays suppressed. Before the test that needs a fresh prompt, run adb shell pm clear-permission-flags <package> android.permission.POST_NOTIFICATIONS user-set user-fixed, or pm clear <package> to wipe app data and permission state together. Resetting state is what stops these tests leaking into each other.
Pie covers the flow a notification kicks off rather than the transport that delivers it. An execution script puts the app into the state the notification announces, Pie enters through the deep link the way a user would, and it checks the resulting screen by what it looks like instead of by a selector. That means the notification regression test survives a redesign of the destination screen.
Pie runs on the iOS Simulator and the Android emulator, which is where the permission, display, and tap-routing layers already live. Those are the layers that carry most notification regressions, so they cover the majority of what breaks. Delivery through APNs or Firebase Cloud Messaging depends on your own provider credentials and belongs in a separate scheduled check.
Adithya Aggarwal
Adithya Aggarwal
CTO & Co-founder at Pie

Eight years building search and delivery systems at Amazon. The kind of scale where flaky tests block billion-dollar releases. Now CTO at Pie, building AI agents that adapt when your UI changes. LinkedIn →