Blog / How to Test Deep Links and Universal Links on iOS and Android
How-To

How to Test Deep Links and Universal Links on iOS and Android

Deep link tests can pass while real links open the browser. Test the OS routing step on iOS and Android, from association files to deferred installs.

When someone taps https://shop.com/product/42, iOS and Android decide whether your app may answer before a single line of your code runs. They decide against a file you host on your domain. A test that calls your handler directly never reaches that check, which is how a green suite and a broken link can both be true.

Split a broken link the way you would split any failure. Did the OS refuse it, or did your router mishandle it? A suite that calls the router has only tested the second half. The first breaks without an error, from something as small as a 301 in front of a JSON file or an association file the phone cached at install and has not rechecked since.

What you’ll learn

  • Why a passing deep link test still ships a broken link
  • Which iOS test methods Apple documents, and which never open your app
  • How to verify App Links on Android without faking the result
  • How to test deferred deep links across the install boundary
  • What deep link coverage in CI can and cannot reach

Why Deep Link Tests Pass and Links Still Break

The operating system’s half is the one that breaks in production, because it depends on a file you host and a cache you do not control. Apple’s associated domains documentation and Google’s App Links verification guide describe two different checks:

iOS universal linksAndroid App Links
File/.well-known/apple-app-site-association/.well-known/assetlinks.json
Who fetches itApple’s CDN, and the device downloads the CDN’s copy at install (iOS 14 and later)The device, from your domain, at install (Android 6.0 and later), once any intent filter sets android:autoVerify=“true”
Hosting ruleshttps, a valid certificate, no redirectshttps, application/json, no 301 or 302 redirects
When it rechecksThe CDN requests your file within 24 hours, and devices check for updates about once a weekAt install, in the background from Android 15 (changes can take up to seven days), and on demand from Android 12 with pm verify-app-links
What failure looks likeThe link opens in the default browserThe link opens in the browser, or in a chooser dialog before Android 12
Fresh copy on a test deviceReinstall the app (Apple offers no CDN invalidation)Reset with pm set-app-links, then pm verify-app-links —re-verify

Caching adds a delay. A link you fix this morning can stay dead for up to about a week on an iPhone that installed yesterday, because that phone keeps the copy it downloaded at install. Apple’s TN3155 debugging technote adds that there is no direct CDN invalidation option.

A failure like that reads as mobile E2E flakiness nobody can reproduce. The link opens the app on one phone and the browser on the next, and the test environment skipped the real entry point.

“Deep link” gets used for three different mechanisms, and each one fails for a different reason. Name which one you are testing before you write the test.

Link typeExampleWhat the OS checksTypical failure
Custom URL schememyapp://product/42Is the scheme registered, and is the app installed?App not installed, so nothing handles the scheme
Universal Link (iOS) / App Link (Android)https://shop.com/product/42Does the domain’s association file authorize this app?Redirected or mismatched association file, so the link opens a browser or a chooser dialog
Deferred deep linkhttps://shop.com/product/42 with no app installedCan the destination survive an install from the store?Attribution lost, user lands on the home screen after install

Custom schemes are the easiest to test and the least like production. They skip domain verification entirely. Deferred links are the hardest, and they are the path a new user takes from an ad or a shared post. Coverage often ends up inverted from risk.

How to Test Universal Links on iOS

Route the URL through the system instead of calling your handler. On the Simulator, xcrun simctl openurl booted "https://yourdomain.com/product/42" hands the URL to the OS, which decides whether your code ever sees it. TN3155 is written around devices and never mentions the Simulator. Treat a Simulator result as directional and confirm on a phone before you ship. Five steps do most of the work:

  1. Association file: Run curl -v https://yourdomain.com/.well-known/apple-app-site-association and look for a 200 over HTTPS, with no redirect and no bot filter in front of it. Then confirm a path matches with sudo swcutil verify -d yourdomain.com -j ./aasa.json -u https://yourdomain.com/product/42 on a Mac.
  2. Entry point: A universal link typed into Safari’s address bar never opens the app. Our own early iOS tests did exactly that, a fair test of a custom scheme and the wrong door for a universal link. Apple’s documented test is to paste the link into Notes and long-press it, and the option you pick there becomes the default for that domain on that device.
  3. Diagnostics: Turn on Developer Mode, then open Settings, then Developer, find the Universal Links section and turn on Associated Domains Development. Open Diagnostics and type the full URL to see whether iOS considers it valid for the installed app.
  4. Fresh copy: While you develop against a server the CDN cannot reach, add ?mode=developer to the applinks: entry in your entitlement so the device skips the CDN and fetches the file from your server. Only development-signed builds honor it, on devices that opted in.
  5. Screen assertion: Verify that the product screen rendered with the right content. A handler that fires and renders the wrong view is still a bug your user will report.

Put malformed links in that suite too. Apple’s universal links guide calls a universal link a potential attack vector into your app and tells you to include improperly formatted URLs when you test URL-handling code. OWASP’s mobile testing guide made the same shift in MASTG v2, where it deprecated its broad universal links test in favor of handler checks such as MASTG-TEST-0395 on missing input validation.

How to Test App Links on Android

The adb command in Google’s own deep link guide is the one command that cannot tell you whether Android will route a link to your app. It tests an intent filter by naming your package:

adb shell am start -W -a android.intent.action.VIEW -d "https://yourdomain.com/product/42" com.your.package

Naming the package limits the intent to your app, and with one candidate there is nothing for Android to decide. The run proves your activity parses the URI. It says nothing about whether Android would have picked you. Google’s App Links verification guide leaves the package out and adds the browsable category:

adb shell am start -a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d "https://yourdomain.com/product/42"

With no package to aim at, the system resolves the URL the way it would for a real tap. Your app opens and verification is working. A browser opens, or a chooser on Android 11 and lower, and it is not.

  1. Verification state: Give the verifier at least 20 seconds after install, as Google advises, then run adb shell pm get-app-links com.your.package on Android 12 and higher. A domain that passed reports verified, and none means the agent may not have finished. A legacy_failure or an error code of 1024 or higher means verification failed or could not be performed. On Android 11 and lower, adb shell dumpsys package domain-preferred-apps shows always for an app that passed.
  2. Hosting rules: Verification starts only when at least one intent filter sets android:autoVerify="true", and then Android checks every host in your web intent filters. Google’s assetlinks.json rules are https, content type application/json, and no 301 or 302 redirects. Its troubleshooting guide adds that an http-to-https or bare-domain-to-www hop can make verification fail. Android 11 and lower are stricter, making the app the default handler only if every host in the manifest verifies.
  3. Signing key: The SHA-256 fingerprint must match the key that signed the build under test, written in uppercase. The same troubleshooting guide lists “Signing the app with a debug certificate and only having the release signature in assetlinks.json” as a common mistake, and the file can carry both fingerprints. With Play App Signing, copy the release fingerprint from Play Console, because the fingerprint from your local keystore will usually not match.
  4. Forced re-check: From Android 12, reset the device with adb shell pm set-app-links --package com.your.package 0 all, run adb shell pm verify-app-links --re-verify com.your.package, and wait a few minutes before reading the result. Android 12 also raised the stakes. A generic web intent resolves to your app only if your app is approved for the domain, and otherwise it goes straight to the default browser.
  5. Resolution trace: On Android 17 and higher, add --debug-link to the package-less am start to see which apps, intent filters and dynamic rules matched the URL.

Because the fingerprint is bound to the signing key, mobile regression testing for deep links has to run against the real signed artifact. A build signed with another key can fail where the release passes, or pass where it fails.

Screen Checks Breaking Every Build?

Describe the screen in plain English. Pie checks what renders.

How to Test Deferred Deep Links (App Not Installed)

Deferred deep linking covers the user who does not have your app yet. Someone with no app installed taps a link and falls through to the App Store or Play Store. After the install, the first launch should land on the exact screen the link pointed to. The destination has to survive an install. So does the test.

You cannot start this test by launching the app. The app is not there yet. The real sequence looks like this:

  1. Clean device: Uninstall the app. Clearing its data does not recreate a first install.
  2. Real link: Trigger the link and let it route to the store listing. Do not shortcut to the store page. The redirect is part of what you are testing.
  3. Signed build: Install the signed build under test. A sideloaded debug build carries a different signing key.
  4. First screen: Launch and assert that the first screen is the intended destination, not the default home screen.

Attribution across that gap is fragile. Since iOS 14 the system tells the user when an app reads pasteboard content another app put there without the user asking, and since iOS 16 a programmatic paste asks for approval first. A destination carried on the clipboard now comes with a prompt. Whether Branch, Adjust or your own service carries yours, the test has to prove the full install-then-route path, because that is the only place this class of bug lives.

If your flow ran on Firebase Dynamic Links, it has changed at least once. Google shut the service down on 25 August 2025, and every link it served stopped working. Retest the path end to end on whatever replaced it.

On iOS in particular, where the Simulator has no App Store, the install boundary resists automation harder than any other part of deep link testing. The run needs a physical device and someone to hold it. Automate everything up to the store handoff and budget a manual pass for what happens after.

Cold Start vs Warm Start: The Cases Teams Skip

A deep link behaves differently depending on whether your app is already running, and on iOS the two cases run different code. With scenes, the system delivers a link that launches the app to scene(_:willConnectTo:options:) and a link tapped while the app runs or sits suspended to scene(_:continue:), per Apple’s universal links guide. A cold start also has to boot the app and initialize state before it routes, sometimes before authentication or feature flags have resolved.

The same link can land correctly on a warm start and dump the user on a login screen on a cold start, because the router fired before the session existed. Three paths need explicit coverage:

  • Cold start: Force-quit the app (on Android, adb shell am force-stop com.your.package), then trigger the link. Initialization-order bugs show up here, along with links that fire before auth.
  • Warm start: Open the app, background it, then trigger the link. Expect state and navigation-stack bugs.
  • Logged-out state: Trigger a link to a gated screen with no session, and confirm the app returns to the destination after login instead of forgetting it.

Three link types, two platforms and three launch states make up to 18 runs before a single malformed URL. That repetitive matrix is what gets cut under deadline, first here and then in app permission states and every other OS-owned behavior your suite has to set up from outside the app.

The deep link test that holds

A deep link test is only meaningful when it reproduces the routing decision the operating system makes. Launch it from outside the app, on the signed build, including the cold start.

Getting Deep Link Tests into CI

Start with the half that has no UI. These commands run in any pipeline, and they catch the 301 and the wrong fingerprint before anyone taps anything:

# Association files: status, content type, redirects
curl -sI https://yourdomain.com/.well-known/apple-app-site-association
curl -sI https://yourdomain.com/.well-known/assetlinks.json

# Android 12+ emulator, at least 20 seconds after install
adb shell pm get-app-links com.your.package

# Route a real link through the OS (confirm a Simulator result on a phone)
xcrun simctl openurl booted "https://yourdomain.com/product/42"
adb shell am start -a android.intent.action.VIEW -c android.intent.category.BROWSABLE -d "https://yourdomain.com/product/42"

The trouble starts when the test has to look at a screen. The entry point is another app, a browser or a messaging client, and what it shows varies with the OS version. A selector suite written against one version’s screens breaks on the next and flakes its way into quarantine. It is the selector upkeep that makes mobile test automation expensive to keep green.

Pie keeps the destinations under test, the product page or the gated screen a link is supposed to open. It runs your builds on iOS simulators and Android emulators, from a simulator build rather than a device build on iOS. It explores the reachable, supported flows that lead to those screens and checks what renders by looking at it. Your team authors supported checks in plain English without maintaining selectors.

A Deep Link Test Checklist

Deep links sit near the end of a release checklist, after the broader mobile app tests have run. They are easy to wave through. A broken one fails silently. Per platform, cover all of this:

  • Association files (apple-app-site-association, assetlinks.json) return 200 over https with a valid certificate and no redirects, the AASA file answers any user agent, and assetlinks.json is served as application/json.
  • At least one web intent filter sets android:autoVerify="true", and every host in your web intent filters serves its own assetlinks.json.
  • The SHA-256 fingerprint in assetlinks.json is uppercase and matches the exact build’s signing key, including the Play App Signing key.
  • Each universal link and App Link opens the app with no browser fallback or chooser dialog, tested with the package name left out of the command.
  • Custom-scheme links route correctly when the app is installed and fail gracefully when it is not.
  • Cold start, warm start, and logged-out start each land on the correct screen.
  • Deferred deep links survive an uninstall, install, and first launch.
  • Invalid and malformed link paths fail safely instead of crashing or landing on a blank screen.

Whichever line is unchecked is the link your users will report broken. Start there.

Ask the OS First

Deep links break in production in the half the operating system owns. Everything from the association file to the attribution lost across an install sits outside your router, and a test that calls your handler sees none of it.

Put the association checks and both OS routing commands into CI. Before a release, long-press the link in Notes and keep one manual pass across the install boundary.

Pie keeps the screens behind those links under test. Make the OS answer first, and a green run starts meaning what it says.

Test the Screens Behind Your Links

Keep the adb checks for routing. Hand Pie the screens they lead to.

Frequently Asked Questions

A deep link opens a specific screen in an app. A custom-scheme link only works once the app is installed.

A universal link or App Link is an https URL the OS hands to your app after verifying your domain. A deferred deep link keeps its destination through an install.

Because a test that calls your router directly only proves the handler works.

On a phone, the OS first decides whether to hand the URL to your app, using the association file you host. A redirect, a fingerprint mismatch or a stale cached copy stops it there.

Run xcrun simctl openurl booted with the full URL so the OS routes it, instead of calling your handler from the test. Apple's debugging technote never mentions the Simulator, so confirm the result on a phone with Associated Domains Development turned on.
Use Google's verification command. Run adb shell am start with the VIEW action, the BROWSABLE category and your https URL, and leave the package name off so the system resolves the link itself. Adding your package sends the intent straight to your app and tests only the handler.

Verification failed. Android checks assetlinks.json at install, and from Android 15 in the background too.

A redirect or a content type other than application/json fails it, as does a SHA-256 fingerprint that does not match the signing key. A debug build checked against a release-only file is a common cause.

Cross the install boundary for real. Uninstall the app, trigger the link, let it route to the store, install the build, and check that the first launch lands on the intended screen.

Plan the iOS pass on a physical device, since the Simulator has no App Store to install from.

Most of it can. The association-file checks, pm get-app-links, simctl openurl and a package-less am start all run in a pipeline against simulators and emulators.

The store install behind a deferred link is the exception. Keep one manual pass for it on a device before each release.

An iOS simulator build as a .app, zipped or not, or an Android .apk, plus working test credentials for any screen behind a login.

Pie runs the build on iOS simulators and Android emulators, explores the reachable, supported flows to those screens, and checks what renders without selectors to maintain.

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 →