The Deep Link QA Matrix: 12 Cases for Deep Link Testing
Two weeks. That's how long a fintech app I looked at ran with dead universal links after a routine release, and nobody noticed. Their "organic" install count climbed. The team celebrated.
Here's what was actually happening. Their universal links had gone dormant after reinstall, every tap routed to Safari, and Safari installs landed in the organic bucket instead of the paid campaigns that earned them. Two weeks of misattributed spend. No error, no alert, just a number that looked good and was wrong.
Here's the extractable answer. Deep link testing means verifying behavior across four axes (app installed vs absent, cold vs warm start, iOS vs Android, browser vs in-app webview) because each combination hits a different OS routing path, and three of those cells break in production more than the rest combined. That's the whole thesis. The rest is the matrix and the commands to prove it.
Why deep links fail silently
Two files decide whether a link opens your app or dumps the user into a browser. On iOS it's the apple-app-site-association file (AASA). On Android it's assetlinks.json. Both fail without any user-facing signal, which is why QA is your only detection layer.
The iOS side is unforgiving. Apple's own docs state the uncompressed AASA file "must be no greater than 128 KB, regardless of whether the file is signed," and it has to be served over HTTPS at /.well-known/apple-app-site-association with no redirect and no .json extension. Get one character out of sync with the signed applinks: entitlement and iOS routes to Safari. No crash, no toast, no log line the user ever sees.
Since iOS 14 there's a second wrinkle. The device no longer fetches the AASA file itself. As JBS Dev documented, "the device will request this information from Apple, whose CDN will make the request to the domain." That CDN caches the file and refreshes it roughly weekly. So your AASA can be correct on the server and stale on the device at the same time, which is the single most confusing state to debug because nothing you can see is wrong.
Android fails differently but just as quietly. Per the Android developer docs, when you set autoVerify, "for each unique hostname found in the intent filters, Android queries the corresponding websites for the Digital Asset Links file at https://hostname/.well-known/assetlinks.json." That check runs asynchronously and can take at least 20 seconds after install. If the signing fingerprint doesn't match, or a 301 sits in the path, verification fails. And since Android 12 removed the chooser dialog, the link just opens the browser with no prompt and no warning.
The 12-case QA matrix
Four binary axes give you 16 combinations on paper. In practice four collapse: cold vs warm start doesn't change routing when the app is absent, because there's no running process to be warm. So we prune to 12 meaningful cells, each with a distinct expected behavior.
| # | Platform | App state | Start | Context | Expected behavior |
|---|---|---|---|---|---|
| 1 | iOS | Installed | Cold | Browser | Universal link opens app to routed screen |
| 2 | iOS | Installed | Warm | Browser | Handoff to app, routed screen, no relaunch |
| 3 | iOS | Installed | Cold | Webview | Webview intercepts; needs explicit open-in-app or fails to Safari |
| 4 | iOS | Installed | Warm | Webview | Same as #3; webview rarely hands off |
| 5 | iOS | Absent | — | Browser | Store, then deferred deep link to routed screen post-install |
| 6 | iOS | Absent | — | Webview | Store, deferred link often lost — highest-risk cell |
| 7 | Android | Installed | Cold | Browser | Verified App Link opens app to routed screen |
| 8 | Android | Installed | Warm | Browser | Handoff to running app, routed screen |
| 9 | Android | Installed | Cold | Webview | Webview swallows tap; needs intent handling |
| 10 | Android | Absent | — | Browser | Play Store, then deferred link to routed screen |
| 11 | Android | Absent | — | Webview | Store, deferred link at risk |
| 12 | Android | Installed | Cold | Browser, unverified build | Falls to browser silently — regression canary |
Cell 12 is deliberate. Run one build where verification is expected to fail so you know your test actually detects the failure. A QA suite that only ever passes is a suite you don't trust. I've watched teams ship a green run for a month before someone realized the harness never once exercised the broken path — it was testing the happy case and calling it coverage.
The three cells that break most often
Say you only have time to test three cells before a release. Test 3, 6, and 12 by their failure mode. Here's why each one earns the spot.
In-app webviews that never hand off to the OS
When someone taps your link inside Instagram, TikTok, or the Gmail app, the tap lands in an embedded webview. Not Safari. Not Chrome. Not the OS URL router. Universal links and App Links are both OS-level mechanisms, and the webview never asks the OS to route the URL, so your carefully configured AASA is irrelevant. The link resolves to your web page inside the social app and stops there.
There's no fix at the AASA layer. You handle this with an explicit "open in app" interstitial or a click-to-app JavaScript hop that forces the OS handoff. Skip this cell in QA and you'll ship a link that works perfectly from Safari and dies for every user who came from a paid social campaign. Which is most of them, and it's the traffic you're paying the most for.
Dormant universal links after reinstall
This is the story from the top. Because Apple's CDN caches the AASA file and refreshes it roughly weekly, a device that reinstalls your app can pull a stale association. You changed the AASA last Tuesday. The CDN still serves last week's copy. The device trusts the CDN. Your links route to Safari for the gap between deploy and refresh.
Reinstall is the trigger nobody tests, because fresh installs on a clean simulator always work. Add "delete, reinstall, tap link" to cells 5 and 7. That single step surfaces the exact failure that cost the fintech app two weeks.
Mismatched assetlinks fingerprints
On Android, the file has to list the SHA-256 fingerprint of the signing key that actually shipped the build. Play App Signing means the key that signs your production APK is often not the key on your local machine. Ship the wrong fingerprint and verification fails. A 301/302 redirect on the assetlinks path fails it too, and so does a robots.txt block. Since Android 12, none of these produce a chooser dialog — the link opens the browser with no signal. The classic case: staging works, production doesn't, because staging and production sign with different keys.
Verification commands per platform
Two files, two toolchains. Run these. Don't eyeball config.
iOS
Trigger a universal link on the simulator as if the user tapped it. Objective Tidbits documented that "xcrun supports opening URLs on iOS simulator as if they have been triggered by a user action":
xcrun simctl openurl booted "https://yourdomain.com/product/123"
Inspect exactly what Apple's CDN cached. Per the jbranchaud/til notes, you view the cached association by appending your domain to the CDN URL:
https://app-site-association.cdn-apple.com/a/v1/yourdomain.com
If that shows a stale or wrong file, the device does too. To bypass the CDN entirely during QA, enable Associated Domains Development under Settings > Developer and append ?mode=developer to your entitlement. As David Yang's write-up shows, applinks:yourdomain.com?mode=developer makes the device fetch directly and skip the cache. On device, swcutil dl dumps the swcd association state so you can confirm what iOS actually resolved.
Android
Query the verification state directly:
adb shell pm get-app-links your.package.name
Look for verified next to each host. If it says anything else, verification failed. To re-run it manually you have to reset state first. The Android docs are explicit: "before you manually invoke domain verification on a device, you must reset the state of Android App Links on the test device." Then:
adb shell pm set-app-links --package your.package.name 0 all
adb shell pm verify-app-links --re-verify your.package.name
For the hosted file, Google's guidance is to "use the Digital Asset Links API to ensure that your JSON file is correctly formatted and accessible" before you trust any on-device result. Server correct, device verified — you need both green, and confirming one without the other is how the stale-cache bug slips through.
Worked example: catching a dormant link before it costs you
Take one release through cells 5 and 7 with a reinstall step. Small app, a few tens of thousands of installs a month.
QA runs cell 7 on Android: delete, reinstall, tap the campaign link. Opens the app. Fine. Then cell 5 on iOS with the same reinstall: the link opens in Safari. Red flag. They hit the CDN URL — app-site-association.cdn-apple.com/a/v1/yourdomain.com — and see last release's AASA, missing the new path pattern. The server file is correct. The CDN is stale.
Now the money side. Suppose a slice of your monthly installs relies on deferred deep linking through that path. If those shipped broken for the weekly cache window, they'd misattribute as organic instead of paid. At any real CPI, that's paid spend that would've looked free while the campaign that funded it showed no return. The kind of result that gets a good campaign cut by mistake.
The matrix caught it because someone added the reinstall step. The command confirmed root cause in under a minute. That's the point of running both together. For the attribution side of why these show up as "organic," our piece on MMP and store install discrepancies covers the bucketing mechanics.
Where automation shifts the QA burden
Hosted link services handle several of these cells for you. Deferred deep linking (cells 5, 6, 10, 11) and short-link routing are exactly the fallbacks platforms automate — including services like Kixo via its kixo.cc short links, which manage some of the deferred and fallback routing. That narrows your QA to the cells the service doesn't own — mainly the webview handoff (3, 4, 9) plus your own signing config in cell 12 — rather than eliminating testing. If you're weighing providers, the routing-and-fallback tradeoffs are laid out in our deep linking platforms comparison, and QR entry points get their own hop-by-hop treatment in the QR attribution piece.
Run this before every release
The matrix is a gate, not an audit. CDN caches drift on their own schedule. Signing configs change when someone rotates a key. An AASA that passed last sprint can go stale this one without a single code change on your side.
So run the 12 cells, or at minimum cells 3, 6, and 12, before every release. Confirm the CDN copy. Confirm the fingerprint. The fintech team skipped it and ate two weeks of bad attribution nobody caught. That check takes about twenty minutes. Do the math.