Skip to content
Tolinku
Tolinku
Sign In Start Free
iOS Development · · 6 min read

Handling Universal Links in Xcode Previews and Tests

By Tolinku Staff
|
Tolinku universal links dashboard screenshot for ios blog posts

Universal Links routing logic can be complex: parsing URLs, extracting parameters, mapping paths to screens, and handling edge cases. Testing this logic in Xcode ensures that your deep links work before they reach production. You can unit test URL parsing and routing, UI test the full launch-from-URL flow, and use Xcode's debugging tools to inspect Universal Link behavior on devices.

This article covers how to test Universal Links at each level, from unit tests to UI tests to on-device debugging.

For Universal Links fundamentals, see universal links: everything you need to know. For general testing approaches, see testing Universal Links.

Unit Testing URL Routing

Your URL routing logic should be a pure function: give it a URL, get back a route or destination. This makes it easy to unit test without any iOS infrastructure.

import XCTest
@testable import YourApp

class UniversalLinkRouterTests: XCTestCase {

    let router = DeepLinkRouter()

    func testProductURL() {
        let url = URL(string: "https://example.com/product/abc123")!
        let route = router.route(for: url)
        XCTAssertEqual(route, .product(id: "abc123"))
    }

    func testCategoryURL() {
        let url = URL(string: "https://example.com/category/shoes")!
        let route = router.route(for: url)
        XCTAssertEqual(route, .category(slug: "shoes"))
    }

    func testUnknownPath() {
        let url = URL(string: "https://example.com/unknown/path")!
        let route = router.route(for: url)
        XCTAssertNil(route)
    }

    func testQueryParameters() {
        let url = URL(string: "https://example.com/search?q=red+shoes&page=2")!
        let route = router.route(for: url)
        XCTAssertEqual(route, .search(query: "red shoes", page: 2))
    }

    func testMalformedURL() {
        let url = URL(string: "https://example.com/product/")!
        let route = router.route(for: url)
        XCTAssertNil(route, "Empty product ID should not route")
    }
}

Structuring Your Router for Testability

The key to testable Universal Links is separating URL parsing from navigation. Your router should return a value (an enum case, a struct, a route identifier), not directly perform navigation:

enum DeepLinkDestination: Equatable {
    case product(id: String)
    case category(slug: String)
    case search(query: String, page: Int)
    case profile(username: String)
    case home
}

struct DeepLinkRouter {

    func route(for url: URL) -> DeepLinkDestination? {
        let path = url.pathComponents

        guard path.count >= 2 else { return nil }

        switch path[1] {
        case "product":
            guard path.count >= 3, !path[2].isEmpty else { return nil }
            return .product(id: path[2])
        case "category":
            guard path.count >= 3 else { return nil }
            return .category(slug: path[2])
        case "search":
            let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
            let query = components?.queryItems?.first(where: { $0.name == "q" })?.value ?? ""
            let page = Int(components?.queryItems?.first(where: { $0.name == "page" })?.value ?? "1") ?? 1
            return .search(query: query, page: page)
        case "user":
            guard path.count >= 3 else { return nil }
            return .profile(username: path[2])
        default:
            return nil
        }
    }
}

This router is a pure function with no side effects, making it trivial to test with any URL you can construct.

Xcode UI tests can launch your app with a Universal Link URL using the XCUIApplication launch arguments. This tests the full flow from URL to screen.

import XCTest

class UniversalLinkUITests: XCTestCase {

    func testProductDeepLink() {
        let app = XCUIApplication()
        app.launchEnvironment["TEST_DEEP_LINK"] = "https://example.com/product/abc123"
        app.launch()

        // Assert the product screen is visible
        XCTAssertTrue(app.navigationBars["Product Details"].waitForExistence(timeout: 5))
        XCTAssertTrue(app.staticTexts["abc123"].exists)
    }

    func testCategoryDeepLink() {
        let app = XCUIApplication()
        app.launchEnvironment["TEST_DEEP_LINK"] = "https://example.com/category/shoes"
        app.launch()

        XCTAssertTrue(app.navigationBars["Shoes"].waitForExistence(timeout: 5))
    }
}

In your app code, handle the test deep link during launch:

func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {

    #if DEBUG
    if let testURL = ProcessInfo.processInfo.environment["TEST_DEEP_LINK"],
       let url = URL(string: testURL) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
            self.handleUniversalLink(url)
        }
    }
    #endif

    return true
}

This approach simulates the deep link within your app's process. It does not test iOS's AASA validation or the system-level Universal Link dispatch, but it does test your app's URL handling end-to-end.

Testing With xcrun and Simctl

For testing on the Simulator, you can open a Universal Link using the xcrun simctl command:

xcrun simctl openurl booted "https://example.com/product/abc123"

This sends the URL to the Simulator's frontmost app as if the user tapped a Universal Link. If your app is installed and the AASA file is configured, the app will open and receive the URL through the standard delegate methods.

Limitations of Simulator Testing

  • The Simulator does not always validate AASA files the same way as a physical device.
  • Apple's CDN caching behavior is different (or absent) on the Simulator.
  • For reliable Universal Link testing, always verify on a physical device.

For on-device testing strategies, see testing Universal Links.

Testing the AASA File

You can validate your AASA file in Xcode without running the app:

Xcode Console Diagnostics

On iOS 16+, connect a physical device and open the Console app (or Xcode's device logs). Filter for swcd (the Shared Web Credentials daemon) to see Universal Link validation messages:

swcd: Checking apple-app-site-association for domain example.com
swcd: Found valid association for TEAMID.com.example.app

If validation fails, you will see error messages indicating what went wrong (invalid JSON, missing Team ID, etc.).

Associated Domains Diagnostics

In Xcode 14+, you can use the Associated Domains diagnostic tool:

  1. Connect your device.
  2. Open Window > Devices and Simulators.
  3. Select your device.
  4. Right-click and choose "Show Associated Domains Diagnostics."

This shows which domains are associated with which apps and whether validation succeeded.

For AASA debugging, see debugging AASA files.

SwiftUI Preview Testing

If you use SwiftUI, you can preview screens that would be reached via Universal Links by passing the relevant data directly:

struct ProductView: View {
    let productID: String

    var body: some View {
        Text("Product: \(productID)")
    }
}

#Preview {
    ProductView(productID: "abc123")
}

This does not test the URL routing itself, but it lets you verify that the destination view renders correctly with the parameters a Universal Link would provide.

For SwiftUI-specific deep link handling, see Universal Links with SwiftUI.

Automated AASA Validation in CI

You can add AASA validation to your CI pipeline to catch configuration errors before they reach production:

#!/bin/bash
# validate-aasa.sh

DOMAIN="yourdomain.com"
AASA_URL="https://${DOMAIN}/.well-known/apple-app-site-association"

# Fetch and validate JSON
RESPONSE=$(curl -s -w "\n%{http_code}" "$AASA_URL")
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | sed '$d')

if [ "$HTTP_CODE" != "200" ]; then
    echo "FAIL: AASA returned HTTP $HTTP_CODE"
    exit 1
fi

# Validate JSON syntax
echo "$BODY" | python3 -m json.tool > /dev/null 2>&1
if [ $? -ne 0 ]; then
    echo "FAIL: AASA is not valid JSON"
    exit 1
fi

# Check for applinks section
echo "$BODY" | python3 -c "
import json, sys
data = json.load(sys.stdin)
if 'applinks' not in data:
    print('FAIL: No applinks section')
    sys.exit(1)
details = data['applinks'].get('details', [])
if not details:
    print('FAIL: No details in applinks')
    sys.exit(1)
for d in details:
    app_ids = d.get('appIDs', [d.get('appID', '')])
    if not any(app_ids):
        print('FAIL: No appID in details entry')
        sys.exit(1)
print('PASS: AASA is valid')
"

Run this script as part of your CI build to catch AASA issues early.

Tolinku manages your AASA file, so you can focus your testing on the app-side routing logic. The AASA file hosted by Tolinku is always valid JSON with correct formatting. Your tests can focus on verifying that your DeepLinkRouter correctly maps Tolinku-managed URLs to the right screens. See the Universal Links developer guide for configuration details.

For Xcode-specific configuration of the Associated Domains entitlement, see xcode Universal Links configuration. For the complete Universal Links guide, see universal links: everything you need to know.

Get deep linking tips in your inbox

One email per week. No spam.

Ready to add deep linking to your app?

Set up Universal Links, App Links, deferred deep linking, and analytics in minutes. Free to start.