How to Test Deep Links on iOS and Android: Commands and Checklist
Test deep links on iOS and Android with real commands: simctl and adb, Universal Link and App Link verification, deferred installs, and a checklist.
TL;DR: To test deep links, split the problem into four independent things and test each one separately: the custom scheme, the Universal Link or App Link, cold start versus warm start, and the deferred case where the app is not installed yet. On iOS the fastest loop is
xcrun simctl openurl booted "https://links.example.com/product/123"against a simulator, and on a device it is a real tap from the Notes app, because typing a URL into Safari's address bar never opens an app. On Android the equivalent pair isadb shell am start -W -a android.intent.action.VIEW -d "https://links.example.com/product/123" com.example.myappto exercise your intent filter andadb shell pm get-app-links com.example.myappto see whether the system actually verified your domain. Deferred deep links cannot be tested with an Xcode rerun or a sideloaded APK: iOS needs a real install from TestFlight or the App Store, and Android needs an install that came through Google Play, which in practice means the internal testing track. Everything below is the command list, a matrix that says which combination proves what, a CI recipe, and a checklist to run before a release.
How to Test Deep Links: What You Are Actually Testing
"The deep link is broken" is four different bugs wearing one sentence. Before you run a single command, be clear about which of them you are hunting.
- URL resolution. Does the OS hand the URL to your app at all? On iOS this is the Associated Domains entitlement plus the
apple-app-site-association(AASA) file. On Android it is the intent filter plusassetlinks.jsonand the verifier's recorded state. A custom scheme skips both. - Routing. Once your process has the URL, does it parse it and land the user on the right screen? This is your code, and the only part a scheme test exercises cleanly.
- Launch state. Cold start (app not running) and warm start (app in the background) go through different entry points on both platforms. They fail independently, and a suite that tests only one of them is the most common reason a bug ships.
- Deferred. The app is not installed when the link is tapped. The user goes to the store, installs, and expects to land on the original destination on first launch. None of the other three tests touch this.
Keep those four separate in your test plan. The tools differ for each, and a single failing tap tells you almost nothing about which one broke.
The Test Matrix
Every link type behaves differently in each install state. This is the table to fill in, once per platform, before a release.
| Link type | App not installed | Installed, app closed (cold start) | Installed, app in background (warm start) |
|---|---|---|---|
Custom scheme (myapp://) | Nothing happens, or the browser shows an error | App launches, URL arrives in the launch or open-URL handler | App foregrounds, URL arrives in the open-URL handler |
| Universal Link (iOS) | Opens in the browser at your fallback page | App launches, URL arrives as an NSUserActivity | App foregrounds, URL arrives as an NSUserActivity |
| App Link (Android) | Opens in the browser at your fallback page | Activity created with the VIEW intent, read it in onCreate | Existing activity receives onNewIntent, or is recreated depending on launch mode |
| Deferred link | Store page, then routing on the first launch after install | Not applicable | Not applicable |
The App Link warm-start row is launch-mode dependent: with singleTop or singleTask you get onNewIntent, and with the default standard mode you get a new activity instance and onCreate. Test the one your manifest actually declares.
A fixed test link set
Do not improvise links during testing. Build a set once and reuse it every release:
| Purpose | Example |
|---|---|
| Simple content route | https://links.example.com/product/123 |
| Route with query parameters | https://links.example.com/product/123?variant=blue&ref=test |
| Route behind authentication | https://links.example.com/orders/456 |
| Route your app does not claim | https://links.example.com/legal/terms (should stay in the browser) |
| Custom scheme equivalent | myapp://product/123 |
| Deferred candidate | any of the above, tapped with the app uninstalled |
The fourth row matters more than it looks: a catch-all path pattern that claims every URL on your domain is a real bug, and the only way to catch it is to test a URL that should not open the app.
Testing Deep Links on iOS
xcrun simctl openurl: the fastest loop
The simulator gives you a sub-second feedback loop. Boot a simulator, install the app, then:
# Confirm which simulator is booted
xcrun simctl list devices booted
# Open a Universal Link on the booted simulator
xcrun simctl openurl booted "https://links.example.com/product/123"
# Open a custom scheme URL on the booted simulator
xcrun simctl openurl booted "myapp://product/123"
booted targets whichever simulator is running, and you can substitute a specific device UDID from the first command. Always quote the URL, or your shell will eat the ampersands.
What this proves: the URL reaches the system, and if the association is valid the system hands it to your app. What it does not prove: anything about your production domain association on a real device, since the two do not share association state.
Notes app long-press: the real-device test
On a physical device the canonical test is a tap, and Apple is explicit about how to produce one. From TN3155: Debugging universal links:
To test your universal links behavior, paste a link into your Notes app and long-press it (iOS) or control-click it (macOS) to see your options for following the link. If universal links have been configured correctly, the option to open in your app and in the web browser will both show up.
Two things follow. The option you pick sets the default for that domain on that device, so if you once chose the browser, the device keeps doing that until you repeat the long-press and pick the other option. And if the menu never offers your app at all, the association failed, and no amount of app-side debugging will help.
Why the Safari address bar never works
This is the single most common false negative in deep link testing. Typing or pasting a URL into the address bar is direct navigation, not a link tap, and Apple states it plainly:
Entering the URL directly into the web browser's address bar will never open the app, as this is direct navigation within the web browser.
The same principle explains the other classic surprise: a link to the domain you are already browsing does not open the app either, because the browser treats it as continued navigation on the same site. If your login button links from example.com to example.com/login, the app never opens. Apple's fix is to put the link on a different subdomain, such as foo.example.com, with its own AASA file. It is also why link services use a separate short-link domain.
Developer settings: Associated Domains Development and Diagnostics
iOS ships a built-in association debugger. Enable Developer Mode in Settings, then, per TN3155:
In Settings > Developer, scroll to the section labeled Universal Links and turn on Associated Domains Development. Open Diagnostics and type in your full URL. You will receive feedback on whether this link is valid for an installed app.
Diagnostics answers the exact question you care about, which is whether this specific URL resolves to an installed app on this device. It is faster than reading any log.
The toggle pairs with a change on the app side. Appending ?mode=developer to the entitlement makes the device fetch your AASA file straight from your server instead of the cached copy:
applinks:links.example.com?mode=developer
Use it while iterating on the file, and remove it before you ship. Without it, App Store and TestFlight builds read the copy Apple's content delivery network cached, which refreshes roughly once a week and cannot be purged on demand. Reinstalling the app is the only way to force a fresh download.
swcutil on a Mac
swcutil is the command line front end for the same association machinery, and the fastest way to prove your file is servable and your path patterns match. Run sudo swcutil on its own to list the subcommands. The two that matter:
# Confirm the association file can be downloaded for this domain
sudo swcutil dl -d links.example.com
# Verify a downloaded file, and check a specific URL against its patterns
sudo swcutil verify -d links.example.com -j ./aasa.json -u https://links.example.com/product/123
A successful verify prints the service, App ID, and domain along with the match result:
{ s = applinks, a = ABCD123.com.example.myapp, d = links.example.com }:
Pattern "https://links.example.com/product/123" matched.
If a path you expect to open the app prints blocked match instead, your components array excludes it, or it never matched a pattern at all. That is a file bug, not an app bug, and you found it without touching a device. sudo swcutil show lists the associations the Mac currently holds.
Console.app and swcd
When the file is right and the link still opens the browser, go to the daemon. Association work on device is done by swcd. Connect the device to a Mac, open Console.app, select the device in the sidebar, and filter the process column for swcd. Tap the link and watch the entries: they say whether the file was fetched, parsed, and matched.
For a snapshot rather than a live stream, capture a sysdiagnose and open swcutil_show.txt from the archive. Search for your App ID:
Service: applinks
App ID: 1234abcd.com.example.myapp
Domain: links.example.com
User Approval: unspecified
Site/Fmwk Approval: approved
Last Checked: 2026-08-24 10:09:00 +0000
Next Check: 2026-08-31 21:00:19 +0000
Site/Fmwk Approval: approved means the association passed. unspecified or denied means it did not, and sends you back to the file. User Approval records the choice the user made in that long-press menu, which is how a perfectly configured app still opens the browser on one particular device.
Testing Deep Links on Android
am start: two commands, two different tests
Android gives you two shapes of the same command, and the difference between them is the whole test.
# Targeted: send the URL straight to your package.
# Tests your intent filter and in-app routing, and bypasses system resolution.
adb shell am start -W -a android.intent.action.VIEW \
-d "https://links.example.com/product/123" com.example.myapp
# Untargeted: let the system decide who handles the URL.
# This is the real App Links test.
adb shell am start -a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE \
-d "https://links.example.com/product/123"
The targeted form is documented in Android's deep link guide and the untargeted form in Verify Android App Links. The -W flag waits for the launch to finish and prints the result, including the resolved activity and any error, which makes it the better choice in scripts.
A passing targeted command with a failing untargeted command is the signature of unverified App Links: your app can handle the URL, but the system does not believe it owns the domain, so the browser wins. Custom schemes use the same targeted form:
adb shell am start -W -a android.intent.action.VIEW -d "myapp://product/123" com.example.myapp
pm get-app-links: what the device actually recorded
This is the most informative Android command:
adb shell pm get-app-links com.example.myapp
# Restrict to the current user, which matters on devices with work profiles
adb shell pm get-app-links --user cur com.example.myapp
The output lists each declared domain with its verification state. verified is the only value that means App Links work. none means the verifier never ran or the intent filter is missing autoVerify. A number at or above 1024 is a custom error code from the device's own verifier, which Android does not define centrally, so read it as "the verifier rejected this domain" and go back to the file and the fingerprint. legacy_failure means the legacy verifier rejected it.
verify-app-links and set-app-links: forcing a fresh pass
Stale verification state is a frequent cause of a correct configuration still failing. Reset and re-verify:
# Reset the recorded App Links state for every domain in the package
adb shell pm set-app-links --package com.example.myapp 0 all
# Trigger a fresh verification pass
adb shell pm verify-app-links --re-verify com.example.myapp
# Read the result again
adb shell pm get-app-links com.example.myapp
Note the argument order on the reset command: the package flag comes first, then 0 all. It returns the device to the state it was in before any default-app choices were made for those domains. Re-verification needs network access and is not instant, so wait a few seconds before querying the state again.
One caveat on which apps this applies to. An app that targets Android 12 (API level 31) or higher gets the updated domain verification process automatically. For an app that still targets Android 11 (API level 30) or lower, enable it by hand before the commands above mean anything, on a device running Android 12 or higher:
adb shell am compat enable 175408749 com.example.myapp
The chooser dialog is a result, not a bug
If tapping a link shows the "Open with" chooser, that is Android telling you the domain is not verified for your app. The fix is always upstream in assetlinks.json or the signing fingerprint, never in the dialog. You can confirm the same thing at Settings > Apps > your app > Open by default, where a verified domain appears as a supported link.
One scheme-testing gotcha: typing a myapp:// URL into Chrome's address bar does not launch the app, because Chrome treats unknown schemes in the omnibox as search input. Use adb, an intent:// URL, or a real tappable anchor.
Cold Start and Warm Start Are Two Different Tests
The handlers differ, which is why one can work perfectly while the other silently drops the URL.
On iOS with the scene lifecycle, a cold start delivers the link in scene(_:willConnectTo:options:) through connectionOptions.userActivities, while a warm start calls scene(_:continue:). Custom schemes arrive in scene(_:openURLContexts:), and SwiftUI wraps both in .onOpenURL and .onContinueUserActivity.
On Android, a cold start hands you the intent in onCreate through getIntent(), and a warm start calls onNewIntent when the activity uses singleTop or singleTask. Forgetting onNewIntent is the most common Android routing bug, and it only appears when the app is already running.
In React Native, Linking.getInitialURL() covers the cold start and the url event from Linking.addEventListener covers the warm start. The cold start has a race worth testing explicitly: the URL can arrive before your navigator is mounted, so hold the link and replay it once navigation is ready.
To test the cold path honestly, force-quit the app first. On iOS, swipe it away in the app switcher. On Android:
adb shell am force-stop com.example.myapp
Then open the link. Backgrounding the app and opening the link tests the warm path. Run both, on both platforms, for every link type. That is eight taps and two minutes.
Testing Deferred Deep Links
Deferred is the case where the app is not installed at tap time. It is also the case most teams test wrong, because the shortcuts that work everywhere else silently invalidate it.
iOS: TestFlight, not an Xcode rerun
A deferred check runs exactly once per install, on the genuine first launch, and writes a completion marker afterwards so it never runs again for that install. Two consequences:
- Re-running from Xcode is not a fresh install. The marker is already there, the check short-circuits, and you get the cached result. An Xcode install can also assign a new identifier for vendor, changing the device-side signal you are trying to test.
- Deleting the app does reset it. The completion marker lives in a backup-excluded file inside the app container, so it goes with the app and does not come back from a restore.
The faithful loop is therefore: delete the app, tap the test link from Notes so it is a real tap, follow the redirect to the store or the fallback, install from TestFlight, then launch and assert on where you land.
One nuance to expect: a device that has run the app before carries a separate device-seen marker in the Keychain that survives an uninstall. It gates nothing, but it flags the install as a reinstall rather than a first install. Both count. To exercise the true first-install path, erase the simulator with Device > Erase All Content and Settings or xcrun simctl erase, or use a device that has never run the app.
Android: the internal testing track
Android's deferred path is deterministic when the install comes through Google Play, because the Play Install Referrer carries the click parameters straight through. That mechanism exists only for Play installs. A sideloaded APK from adb install has no referrer, and the match falls back to probabilistic fingerprinting, a different code path with different accuracy.
So a real referrer test needs a real Play install, and the internal testing track is the cheapest way to get one:
- Upload the build to the internal testing track and add your test account as a tester.
- Opt in through the tester link on the device so the app is available to that account in Google Play.
- Uninstall any existing copy of the app.
- Tap your test link, let it redirect to the Play listing, and install from there.
- Launch and assert on the routed destination.
The referrer that arrives is a URL-encoded query string holding the campaign parameters attached to the store URL, plus the referrer click and install begin timestamps. Google's Play Install Referrer Library keeps the value available for 90 days and it does not change until the app is reinstalled, so query it once on first launch.
Keep the clock in mind
Probabilistic matching is time-bounded. Match windows are measured in hours, commonly defaulting to around six with a ceiling of a day, and confidence decays across that window. Keep the gap between tapping the link and finishing the install short, ideally under an hour: a test that sat overnight and then failed proves only that the window expired. Check the test device's clock too, since a badly skewed date can land a fresh install outside the window from the server's perspective, which looks exactly like a broken match.
Automating Deep Link Tests in CI
Manual testing catches the bug once. CI catches it every time someone edits the router.
Maestro: one flow, both platforms
Maestro's openLink command opens a URL against a simulator or emulator inside a flow, which makes it the least painful way to run one deep link test on both platforms:
appId: com.example.myapp
---
- launchApp:
clearState: true
- openLink:
link: "https://links.example.com/product/123"
autoVerify: true
- assertVisible: "Product 123"
link is the URL. autoVerify asks the runner to open the link with your app rather than letting Android show the chooser, which is what you want when nobody is there to tap a dialog. A browser option covers the case where you deliberately want the link to land in the browser.
Cover the launch states with separate flows: one launches the app with clearState: true and then opens the link for the warm path, another opens the link with the app stopped for the cold path. Assert on visible in-app content, never on the command's exit code, because the open can succeed while routing quietly fails.
XCUITest on iOS
Since Xcode 14.3 and iOS 16.4, an XCUITest can open a URL through the system:
import XCTest
final class DeepLinkTests: XCTestCase {
func testProductLinkRoutesToProductScreen() {
let app = XCUIApplication()
app.launch()
guard let url = URL(string: "https://links.example.com/product/123") else {
return XCTFail("Malformed test URL")
}
XCUIDevice.shared.system.open(url)
let title = app.staticTexts["product-title"]
XCTAssertTrue(title.waitForExistence(timeout: 10))
XCTAssertEqual(title.label, "Product 123")
}
}
Two caveats before you build a suite on it. The first call with a custom scheme can raise a system confirmation dialog the test has to dismiss, and that choice persists for the simulator. There are also reports of the app launching without receiving the URL in some configurations. If you hit either, drive the open from outside the test process with xcrun simctl openurl booted in the CI script, then run a test that only asserts on the resulting state.
adb in an emulator job
On Android the same idea needs no framework. Gate the pipeline on verification state, then assert on routing:
set -euo pipefail
# Fail the build if any declared domain is not verified
adb shell pm get-app-links com.example.myapp | tee /tmp/app-links.txt
grep -q "verified" /tmp/app-links.txt
# Open the link and wait for the launch to complete
adb shell am start -W -a android.intent.action.VIEW \
-d "https://links.example.com/product/123" com.example.myapp
Link opening on hosted simulators and emulators is flakier than the rest of a UI suite. Retry the open step rather than the whole job, and keep the assertion on in-app state so a retry proves something.
The Deep Link Testing Checklist
Run this before every release, on both platforms.
Resolution
- The association file returns
200with the right content type, no redirect, and no auth wall, on every domain you claim. - iOS:
sudo swcutil verifymatches the URLs you expect and blocks the ones you exclude. - iOS: Settings > Developer > Diagnostics reports your test URL as valid for the installed app.
- Android:
adb shell pm get-app-linksshowsverifiedfor every declared domain. - A URL your app should not claim stays in the browser.
Routing
- Custom scheme opens the right screen on both platforms.
- Universal Link opens the right screen from a Notes long-press on a real iOS device.
- App Link opens the right screen from an untargeted
am starton a real Android device. - Query parameters survive the round trip and reach your handler.
- A link to an authenticated screen routes correctly after login rather than dropping the destination.
Launch state
- Cold start routes correctly after a force-quit, on both platforms.
- Warm start routes correctly from the background, on both platforms.
- Android:
onNewIntentis implemented if the activity usessingleToporsingleTask. - React Native: a link that arrives before the navigator mounts is replayed, not dropped.
Deferred
- iOS: a delete, tap, TestFlight install, launch cycle lands on the link destination.
- Android: an internal testing track install through Google Play lands on the link destination.
- The check runs once on first launch and is not called again later in the session.
- Low-confidence matches fall back to generic onboarding rather than guessing.
- Nothing sensitive, such as auto sign-in, is gated on a probabilistic match.
Regression
- A CI flow opens at least one link per type and asserts on visible in-app content.
- Verification state is re-checked after any change to the domain, the signing configuration, or the path patterns.
Frequently Asked Questions
How do I test a deep link on the iOS simulator?
Boot the simulator, install the app, then run xcrun simctl openurl booted with your URL in quotes. The simulator hands the URL to the system the same way a tap would, so a verified Universal Link opens your app and an unverified one opens Safari. For a custom scheme, pass the scheme URL to the same command.
Why does pasting a link into Safari's address bar not open my app? Because entering a URL directly into the address bar is direct navigation inside the browser, not a link tap, and Apple documents that it will never open the app. Test with a real tappable link instead: paste the URL into the Notes app, then long-press it on iOS or control-click it on macOS and choose to open in your app. The same rule explains why a link to the domain you are already browsing stays in the browser.
How do I test Android App Links verification with adb?
Run adb shell pm get-app-links with your package name and read the state next to each domain, where verified is the only passing value. To force a fresh verification pass, reset the state with pm set-app-links, then run pm verify-app-links with the re-verify flag and query the state again.
How do I test deferred deep links before the app is on the store? On iOS use TestFlight, because an Xcode rerun is not a fresh install and returns the cached first-launch result. On Android use the Play Console internal testing track, because the Play Install Referrer is only set for installs that come through Google Play, and a sideloaded APK falls back to probabilistic matching. Keep the gap between tapping the link and finishing the install short, since the match window is measured in hours.
Can I test deep links in CI?
Yes. Maestro's openLink command opens a URL on a simulator or emulator inside a flow and works the same way on both platforms, and on iOS XCUIDevice.shared.system.open does the equivalent from an XCUITest on Xcode 14.3 and later. Simulator and emulator link opening can be flaky on hosted runners, so retry the open step and assert on visible in-app state rather than on the command's exit code.
Why does my deep link work in the simulator but not on a real device?
Almost always association state rather than routing code. A device fetches the association file through Apple's content delivery network and refreshes it roughly once a week, so a device that installed the app before your fix keeps the old answer until the app is reinstalled. On Android the equivalent is a stale verification state, which pm set-app-links and pm verify-app-links clear.
Related Guides
- When the iOS test fails: Universal Links Not Opening? Every Cause and How to Fix Each One triages the two shapes of failure, and Apple App Site Association Not Working is the file-level checklist behind it.
- When the Android test fails: Android App Links autoVerify Failed covers the
assetlinks.jsonand signing fingerprint causes behind an unverified domain. - Concepts first: The Complete Deep Linking Guide for Mobile Developers explains how schemes, Universal Links, App Links, and deferred links fit together.
- Deferred specifics: Deferred Deep Linking on iOS, on Android, and in React Native.
- Reference: the deep linking concepts page and the deferred deep links concepts page.
How WarpLink Helps
Most of the checklist above is yours forever, because it is about your app: your routing code, your launch handlers, your navigation stack. What WarpLink removes is the hosting half. Register an app and the AASA and assetlinks.json files are generated from your team ID, bundle ID, package name, and signing fingerprints, then served from the link domain and any custom domain you add, with the right content type and no redirects. That deletes the first block of the resolution checklist, which is where most of the debugging hours go.
The three pillars show up in testing too. Linking is the part you just tested. Attribution means the same first-launch check that routes a deferred user also reports which link and campaign drove the install, with a confidence score and a matchGuaranteed flag for when the match is deterministic. Analytics means every one of those test taps appears in real time, which is a useful way to confirm a link fired at all when nothing visible happened on the device.
Create a free WarpLink account and read the deep linking docs to see how the association files and the deferred check are wired.
WarpLink Team
Building affordable, reliable link infrastructure for mobile teams. Deep linking, install attribution, and real-time analytics in one SDK.
Related Posts
Deep Links in Instagram and Facebook In-App Browsers: The Fixes
A deep link tapped in the Instagram, Facebook, or TikTok in-app browser usually will not open your app. What each one does on iOS and Android, and the fixes.
The Complete Deep Linking Guide for Mobile Developers
What is deep linking? Learn how universal links, app links, and deferred deep links work. Covers iOS, Android, and cross-platform implementation.
Firebase Dynamic Links Migration Guide (30 Minutes)
Migrate from Firebase Dynamic Links to WarpLink in 30 minutes. Step-by-step code examples for iOS, Android, and React Native.