Skip to main content
Back to Blog
Guide
2026-07-13

Testing Push-Notification Deep Links

Test push-notification deep links across foreground, background, terminated, authenticated, expired, and invalid routes with reliable mobile test evidence.

Testing Push-Notification Deep Links

The device is locked when an order notification arrives. The user taps it, unlocks, and expects order 4821, not the home screen and not a second copy of the app. That cold-start route crosses the push provider, operating system, application lifecycle, authentication gate, and navigation stack.

Draw the delivery-to-route pipeline

A notification deep link is not one feature. The backend selects a target, the provider transports a payload, the OS displays or delivers it, the app receives a tap callback, a parser validates the destination, and the router resolves it against current user state.

BoundaryEvidence to captureTypical defect
Backend event -> payloadMessage ID, route type, entity IDWrong tenant or stale identifier
Provider acceptanceProvider response identifierAccepted request confused with device delivery
OS presentationVisible title and tap targetData-only message not displayed as expected
Tap callbackSanitized payload and lifecycle stateCallback registered too late on cold start
Route resolverTyped internal destinationUnvalidated arbitrary URI accepted
ScreenHeading and entity identityApp opens default tab instead

Separate transport assertions from navigation assertions. A provider returning success proves it accepted a message, not that a particular phone displayed it.

Build the lifecycle matrix before automating

At minimum, exercise foreground, background, and terminated application states. Add locked device and permission states when they are product requirements.

App state at receipt/tapExpected presentationExpected navigation risk
ForegroundIn-app banner or handled callbackDuplicate routing from banner and push callback
BackgroundSystem notificationExisting navigation stack must resume correctly
TerminatedSystem notificationInitial payload can be consumed before router is ready
Locked deviceSystem presentationProtected content and unlock transition
Notification permission deniedNo system alertIn-app inbox or graceful absence

Test both receipt and tap. A foreground app may receive data without a tap, while a background notification route usually begins from user interaction.

Use a typed internal destination

Avoid letting arbitrary URLs flow directly from a push payload into a web view or router. Parse an allowlisted route name plus constrained identifiers, or validate HTTPS universal links against owned hosts and known paths.

type PushData = Record<string, string | undefined>;

type Destination =
  | { screen: 'OrderDetails'; orderId: string }
  | { screen: 'InboxMessage'; messageId: string }
  | { screen: 'Home' };

export function destinationFromPush(data: PushData): Destination {
  if (data.type === 'order' && /^ord_[a-z0-9]+$/.test(data.orderId ?? '')) {
    return { screen: 'OrderDetails', orderId: data.orderId! };
  }
  if (data.type === 'message' && /^msg_[a-z0-9]+$/.test(data.messageId ?? '')) {
    return { screen: 'InboxMessage', messageId: data.messageId! };
  }
  return { screen: 'Home' };
}

Unit-test that parser aggressively:

import { describe, expect, it } from 'vitest';
import { destinationFromPush } from './push-destination';

describe('destinationFromPush', () => {
  it('maps a valid order target', () => {
    expect(destinationFromPush({ type: 'order', orderId: 'ord_4821' })).toEqual({
      screen: 'OrderDetails',
      orderId: 'ord_4821',
    });
  });

  it.each([
    { type: 'order' },
    { type: 'order', orderId: '../admin' },
    { type: 'https://attacker.example' },
    { type: 'unknown', orderId: 'ord_4821' },
  ])('falls back safely for invalid data %j', (data) => {
    expect(destinationFromPush(data)).toEqual({ screen: 'Home' });
  });
});

The fallback can be a notification inbox instead of Home. What matters is that invalid input never becomes arbitrary navigation or a crash.

Test the backend payload contract independently

Provider APIs differ, but the application payload should have a versioned, testable schema. Do not make screen copy the sole route key because marketing edits can break navigation.

{
  "notification": {
    "title": "Order shipped",
    "body": "Order 4821 is on the way"
  },
  "data": {
    "schemaVersion": "2",
    "type": "order",
    "orderId": "ord_4821"
  }
}
Payload fieldContract check
schemaVersionSupported version or safe fallback
typeAllowlisted route discriminator
Entity IDCorrect format and tenant ownership resolved server-side
Title/bodyNo sensitive lock-screen content beyond policy
Collapse or message keyDeduplication semantics documented
Expiry/TTLStale message behavior aligns with business event

The webhook testing guide is useful for backend event delivery patterns, but a push provider and mobile tap are distinct boundaries. Do not label provider acceptance as an end-to-end mobile test.

Foreground behavior needs a single routing owner

When the app is active, the push SDK callback may show an in-app banner. If the user taps that banner, only the banner handler should navigate. A generic notification-open listener should not process the same message again.

Inject a fake notification event into the app's adapter, assert one banner, tap it, and inspect the navigation spy. Also deliver the same message ID twice to verify deduplication if the provider can redeliver or your event bridge can reconnect.

it('opens one order screen from a foreground banner', async () => {
  const navigate = vi.fn();
  const notifications = createFakeNotificationSource();
  const controller = createPushController({ notifications, navigate });
  controller.start();

  notifications.receive({
    id: 'push-77',
    data: { type: 'order', orderId: 'ord_4821' },
  });
  await controller.tapBanner('push-77');

  expect(navigate).toHaveBeenCalledTimes(1);
  expect(navigate).toHaveBeenCalledWith('OrderDetails', { orderId: 'ord_4821' });
});

These controller names are application test seams, not vendor APIs. Wrap the provider SDK behind a narrow adapter so unit tests do not pretend to emulate the operating system.

Background taps must preserve a sensible stack

If the app is already on Settings and a push opens an order, decide what Back does. Usually it should return to the prior authenticated screen or a stable app root, not exit unexpectedly and not reopen the order.

Assert the destination, selected tab, app bar title, and Back result. Also check that tapping the same notification from the system tray does not push duplicate detail screens when the app is already displaying that order.

Starting screenPush targetExpected Back destination
HomeOrder detailsHome
SettingsInbox messageProduct-defined prior screen or inbox root
Same order detailsSame orderNo duplicate route
Different order detailsNew orderPrevious detail or list, based on navigation policy

Navigation policy must be documented because iOS and Android task-stack conventions differ.

Terminated launch exposes initialization races

On cold start, the initial notification payload may be available before authentication restoration, remote configuration, or the navigation container is ready. Queue one validated destination and consume it after prerequisites complete.

Test three timings: payload available immediately, payload callback delayed, and router readiness delayed. The final navigation should occur once. Restart the app process between cases; backgrounding is not a cold start.

For black-box mobile automation, use the platform's supported simulator or device tooling to terminate and launch the application, then send a test push through a non-production project. Emulator push support and commands vary by platform and provider, so keep provider-specific mechanics in a thin test harness rather than claiming one universal CLI.

Authentication changes the target resolution

A deep link to private content cannot bypass login. Store a sanitized pending destination, authenticate, authorize access to the entity, then navigate. If login is cancelled, clear or retain the pending target according to product policy.

User stateTarget ownershipExpected result
Signed inAuthorizedOpen target
Signed outPotentially authorizedLogin, then resolve target
Signed in as wrong tenantUnauthorizedSafe error or home, no data leak
Account disabledNoneAuthentication failure, no target metadata
Session expires during launchUnknownReauthenticate before fetching entity

Do not put sensitive entity names in an unauthorized error. Even confirming that an order ID exists can leak cross-tenant information.

Stale, deleted, and malformed targets are normal inputs

Notifications can outlive the entity they reference. An order may be deleted, a message may expire, or an app version may not recognize a new route type. These are expected operational states, not exceptional crashes.

Test unknown version, missing ID, invalid ID characters, deleted entity, expired event, unsupported feature flag, and valid ID belonging to another user. The application should route to a stable fallback with clear copy and offer a path forward.

Expired transport TTL prevents some stale deliveries but cannot guarantee business freshness. Validate current authorization and existence when opening the target.

Universal links and custom schemes need security cases

If push data carries a URL, constrain scheme, host, path, query length, and allowed parameters. Reject lookalike hosts, user-info tricks, encoded traversal, dangerous schemes, and open-redirect destinations.

InputExpected handling
https://app.example.com/orders/4821Resolve allowed route
https://app.example.com.evil.test/orders/4821Reject host
javascript:alert(1)Reject scheme
myapp://orders/4821Accept only if custom scheme is supported and parsed
Path with encoded traversalReject during canonical validation

Use the URL parser provided by the platform or language, not substring matching.

Permissions and device tokens belong in the wider suite

Navigation tests can inject tap intents without repeatedly contacting a push network. A smaller end-to-end suite should cover registration permission, device-token upload, token refresh, provider send, visible notification, and tap.

The API testing best-practices guide helps structure backend checks. For push specifically, ensure test device tokens never enter production campaigns and production tokens never enter test logs.

Capture useful evidence without leaking content

Assign a correlation ID from backend event to provider request and app receipt. Log lifecycle state, payload schema version, resolved route type, and fallback reason. Hash or omit device tokens and sensitive entity identifiers.

Screenshots show presentation and destination, while structured logs prove which message drove the route. Video is useful for cold-start races. Provider dashboards can support diagnosis but are not the sole source of truth.

Keep automation layered

LayerFast checksWhat it cannot prove
Parser unit testsValid, malformed, hostile payloadsOS delivery
Navigation integrationLifecycle queue and auth gatingProvider transport
Backend contractCorrect route data and privacyDevice receipt
Simulator/device E2EPresentation, tap, final screenAll real-device vendor conditions
Small physical-device runProduction-like delivery pathBroad deterministic permutations

Most permutations belong below end to end. Reserve real pushes for representative lifecycle and platform cases because provider delivery adds latency and external uncertainty.

A release-focused scenario set

For each supported platform, cover foreground banner, background tap, cold-start tap, signed-out continuation, invalid target, unauthorized entity, duplicate message, and stale entity. Add notification permission denial and locked-screen privacy where required. Verify Back behavior and analytics exactly once per tap.

When navigation architecture changes, rerun the entire lifecycle matrix. A route that works from an in-app link can still fail when the app starts from no mounted navigator.

Extend the plan across delivery and navigation edge cases

Notification replacement and collapse behavior can change which deep link survives. Send two updates with the same collapse identity but different order states, then verify the displayed notification opens the newest valid destination. If the product intentionally keeps both notifications, tapping each must open its own entity rather than a globally cached “last payload.”

Badge counts and notification-center state should reconcile with navigation. Opening one message may mark it read and decrement a badge. Assert idempotency when the same push is tapped twice or the app also fetches an unread-count update. A deep link that routes correctly but double-decrements state is still defective.

Localization affects payload length and truncation, not route identity. Send a supported non-Latin title and body while keeping typed data fields stable. Verify the destination and visible characters. Never parse the localized notification body to obtain an entity identifier.

App upgrades create schema compatibility risk. A notification sent before upgrade may be tapped after the new version launches, and a newer backend may send a route an older app does not know. Test N-1 payloads against the current app and current payloads against the oldest supported app where the release process allows it. Versioned fallback prevents crashes.

Multiple accounts on one device require explicit ownership. If the active account differs from the account targeted by the notification, decide whether to switch accounts, request authentication, or show a neutral message. Do not reveal the other account's order title on the lock screen or in an in-app error.

Device restoration and cloned installations can leave stale tokens registered. Backend tests should remove invalid provider tokens after terminal provider responses according to provider guidance, without deleting a newly refreshed token for the same installation. This is delivery hygiene, separate from route parsing.

Offline taps need a two-stage result. The app can parse and show a cached shell, but fetching the entity may fail. Display a recoverable offline state, retain the validated destination, and navigate once connectivity returns only if the user has not moved elsewhere. An automatic delayed navigation after the user abandons the screen is surprising.

Analytics should record one “notification opened” event with message campaign metadata and resolved route category. It should not include raw device tokens or sensitive message bodies. Cold-start initialization often causes duplicate analytics because both the initial-payload path and general open listener fire; include exact event-count assertions.

Accessibility coverage should check the in-app banner's announcement, focus behavior after navigation, and accessible screen title. System notification accessibility is largely owned by the OS, but the supplied title and body must remain meaningful without visual context. Do not rely only on an image or emoji to convey the event.

Finally, rehearse provider outage and delayed delivery. A delayed notification may refer to completed or cancelled work. The app must resolve current server state rather than rendering the payload as authoritative. Backend retries should use provider idempotency or message identifiers where available and should not create a notification storm after recovery.

Test action buttons separately from tapping the notification body. Approve, Reply, or Mark read can perform background work without following the standard destination. Every action needs authentication, idempotency, and stale-entity behavior.

Grouped notifications add a second route. Tapping a summary should open an inbox or group view, while tapping a child should open that entity. The summary must never inherit an arbitrary child's identifier.

Platform lifecycle details remain important. Android task launch modes can create duplicate activity instances, and iOS scenes can direct a URL to a particular window. Keep platform-specific assertions for instance count, final screen, and Back behavior around the shared parser tests.

Clear the tray and application data only where scenario isolation needs it. A duplicate-delivery test requires both events, while a cold-start test requires the process terminated but the registration token preserved.

Maintain exploratory coverage for OEM battery restrictions, focus modes, notification channels, and lock-screen policies that simulators cannot reproduce faithfully. Record the device model and OS build with evidence.

Frequently Asked Questions

Can an emulator provide a true push-notification end-to-end test?

It can cover much of the path when the platform and provider support emulator delivery, but device services and vendor behavior differ. Keep a small physical-device check for the release risks that require it.

What should happen when a push targets an entity the user cannot access?

The app should navigate to a safe, non-revealing state and avoid confirming sensitive entity details. It must never treat possession of the deep link as authorization.

How do I test a terminated-state notification reliably?

Terminate the application process, arrange the push, tap the system notification, and wait on a semantic destination assertion. Backgrounding alone does not exercise cold-start initialization.

Should the payload contain a raw URL or a route name?

A typed route discriminator with validated identifiers is easier to constrain. If URLs are required, allowlist schemes, hosts, paths, and parameters through a real URL parser.

How can I detect duplicate navigation from one notification?

Record a stable message ID, assert one route action and one analytics event, and verify that foreground, tap, and initial-payload handlers share deduplication ownership.

Testing Push-Notification Deep Links | QASkills.sh