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 links | Android App Links | |
|---|---|---|
| File | /.well-known/apple-app-site-association | /.well-known/assetlinks.json |
| Who fetches it | Apple’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 rules | https, a valid certificate, no redirects | https, application/json, no 301 or 302 redirects |
| When it rechecks | The CDN requests your file within 24 hours, and devices check for updates about once a week | At 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 like | The link opens in the default browser | The link opens in the browser, or in a chooser dialog before Android 12 |
| Fresh copy on a test device | Reinstall 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.
Three Link Types, Three Failure Modes
“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 type | Example | What the OS checks | Typical failure |
|---|---|---|---|
| Custom URL scheme | myapp://product/42 | Is 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/42 | Does 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 link | https://shop.com/product/42 with no app installed | Can 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:
- Association file: Run
curl -v https://yourdomain.com/.well-known/apple-app-site-associationand look for a 200 over HTTPS, with no redirect and no bot filter in front of it. Then confirm a path matches withsudo swcutil verify -d yourdomain.com -j ./aasa.json -u https://yourdomain.com/product/42on a Mac. - 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.
- 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.
- Fresh copy: While you develop against a server the CDN cannot reach, add
?mode=developerto theapplinks: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. - 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.
- Verification state: Give the verifier at least 20 seconds after install, as Google advises, then run
adb shell pm get-app-links com.your.packageon Android 12 and higher. A domain that passed reportsverified, andnonemeans the agent may not have finished. Alegacy_failureor 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-appsshowsalwaysfor an app that passed. - 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 typeapplication/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. - 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.
- Forced re-check: From Android 12, reset the device with
adb shell pm set-app-links --package com.your.package 0 all, runadb 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. - Resolution trace: On Android 17 and higher, add
--debug-linkto the package-lessam startto 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:
- Clean device: Uninstall the app. Clearing its data does not recreate a first install.
- 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.
- Signed build: Install the signed build under test. A sideloaded debug build carries a different signing key.
- 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.
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, andassetlinks.jsonis served asapplication/json. - At least one web intent filter sets
android:autoVerify="true", and every host in your web intent filters serves its ownassetlinks.json. - The SHA-256 fingerprint in
assetlinks.jsonis 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.
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.