Docs

Testing

Test keys, SDK logs, terminal commands that open links, the dashboard Debugger and unit tests for your routing.

Use the test environment

Build development versions of your app with a pk_test_ key and create test links for them. Test links, test installs and test events stay out of your live analytics. Switch the dashboard to the test environment to create and inspect them. See Environments.

Remember that resolving follows the key: a live link opened by an app using a pk_test_ key comes back unresolved, and the other way round.

Turn on logging

await Lander.initialize(apiKey: 'pk_test_...', enableLogging: kDebugMode);

The SDK then prints what it does through debugPrint, prefixed with [Lander]. It never logs your API key. Lines you will see:

Log lineMeaning
Created install ID ...First launch (or a reset install). A new random ID was stored.
Init okThe launch report reached Lander. Events can be sent from now on.
Init failed: LanderException(invalid_api_key, 401): ...The key is wrong or revoked.
Deferred link: LanderLink(...)The first launch report returned a deferred link.
Resolved LanderLink(...)A link was resolved. The line shows its ID, path, params, URL, confidence and method.
Not a Lander link: https://...Lander answered 404 for this URL. It is still delivered, unresolved.
Resolve failed for https://...: LanderException(...)Network error, timeout, or the link is disabled or expired (410). Delivered unresolved.
Ignoring duplicate link ...The same URL arrived twice within 2 seconds.
Sent 3 eventsA batch of events was accepted.
Event batch failed (network_error), retrying in 2sThe queue will try again with backoff.
Event batch rejected (...): check the API key. Pausing until next launch.A 401 or 403 on events. Events are kept for the next launch.
Install ID belongs to another project, creating a new oneYou switched keys between test and live (or projects). Harmless.

You do not need to tap links in Notes or Messages to test. These commands deliver a URL to your app exactly as a tap would.

# Universal Link (needs the apple-app-site-association file to be fetched)
xcrun simctl openurl booted "https://acme.golander.link/p-123"

# Custom scheme (always opens the app; good for testing routing)
xcrun simctl openurl booted "acmeshop://product/123"

# Cold start: stop the app first, then open the link
xcrun simctl terminate booted com.acme.shop
xcrun simctl openurl booted "https://acme.golander.link/p-123"

Run each link twice: once with the app in the background (warm start) and once after stopping it (cold start). Both should land on the same screen, once.

On a real iPhone, paste the link into Notes or Messages and tap it. Typing a Universal Link into Safari's address bar does not open the app; that is iOS behavior, not a setup problem.

Test a first launch

A first launch is what produces a deferred link and an install. Uninstalling removes the install ID, so a reinstall is a fresh first launch:

xcrun simctl uninstall booted com.acme.shop   # iOS Simulator
adb uninstall com.acme.shop                   # Android

Ways to see a deferred link while developing:

  • Authenticated handoff (any device): create a handoff with your sk_test_ key for a test user, sign in as that user in the app and call Lander.claimHandoff. See Authenticated handoff.
  • iOS Continue in app page (real device, with iosLandingPage on): delete the app, tap a test link in Safari, install your build from Xcode instead of the App Store, go back to Safari and tap I already have the app, open it.
  • Android install referrer: needs a real install from Google Play, so flutter run and sideloaded APKs never get one.

Check everything with the Debugger

Open Build > Debugger in the dashboard and paste a link. It checks everything between a tap and your app's screen, in four groups: the link itself, your app configuration (Team ID and bundle ID, package name and fingerprints), the domain files on the edge (apple-app-site-association and assetlinks.json), and whether an app with the SDK has reported in the last 24 hours. Failed checks link to the fix.

Build > SDK shows the SDK versions that apps report in the current environment, so you can confirm a new build is talking to Lander.

Unit test your routing

LanderLink has public constructors, so you can test how your app routes links without a device or the network:

test/routing_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:lander_flutter/lander_flutter.dart';

void main() {
  test('a resolved link routes to its path', () {
    final link = LanderLink(
      uri: Uri.parse('https://acme.golander.link/p-123'),
      linkId: '0b8f9a52-6f1c-4d3e-9a43-3f0d2b7c1e55',
      url: 'https://acme.golander.link/p-123',
      path: '/product/123',
      params: const {'productId': '123'},
      confidence: LanderConfidence.deterministic,
      method: 'universal_link',
    );
    expect(link.isResolved, isTrue);
    expect(link.route, '/product/123');
  });

  test('a custom scheme URL routes without Lander', () {
    final link = LanderLink.unresolved(Uri.parse('acmeshop://product/123?color=red'));
    expect(link.isResolved, isFalse);
    expect(link.route, '/product/123?color=red');
  });
}

For widget tests, let your root widget take the link stream as an optional argument, defaulting to Lander.onDeepLink:

lib/app.dart
class ShopApp extends StatefulWidget {
  const ShopApp({super.key, this.links});

  /// Defaults to Lander.onDeepLink. Tests pass their own stream.
  final Stream<LanderLink>? links;

  @override
  State<ShopApp> createState() => _ShopAppState();
}

class _ShopAppState extends State<ShopApp> {
  late final GoRouter _router = buildRouter();
  StreamSubscription<LanderLink>? _sub;

  @override
  void initState() {
    super.initState();
    _sub = (widget.links ?? Lander.onDeepLink).listen((link) {
      _router.go(link.route, extra: link);
    });
  }

  // dispose() and build() as before
}
test/app_test.dart
import 'dart:async';

import 'package:flutter_test/flutter_test.dart';
import 'package:lander_flutter/lander_flutter.dart';
import 'package:my_app/app.dart';

void main() {
  testWidgets('opens the product screen from a link', (tester) async {
    final links = StreamController<LanderLink>();
    await tester.pumpWidget(ShopApp(links: links.stream));

    links.add(LanderLink.unresolved(Uri.parse('acmeshop://product/123')));
    await tester.pumpAndSettle();

    expect(find.text('Product 123'), findsOneWidget);
    await links.close();
  });
}

The example app

The SDK ships with Acme Shop, in the example folder of the source repository: a go_router app with a product screen at /product/:id. It shows what Lander resolved for each link, creates share links with createLink and tracks purchases with trackEvent. Its bundle ID and package name are com.acme.shop and its custom scheme is acmeshop, and its native setup files are a good reference:

main.dart
app.dart
screens.dart
Info.plist
Runner.entitlements
main/AndroidManifest.xml
debug/AndroidManifest.xml

It is set up to run against a local copy of the Lander monorepo (below), using the seeded demo key, so for most teams it is best read as reference code. The seeded link https://acme.golander.link/p-123 resolves to /product/123.

Against a local Lander API

This is for working on Lander's own monorepo. With it running (pnpm dev), the API listens on port 8787 and a seeded test key exists:

import 'dart:io';

await Lander.initialize(
  apiKey: 'pk_test_DemoPublishableKey000000000000',
  baseUrl: Platform.isAndroid ? 'http://10.0.2.2:8787' : 'http://localhost:8787',
  enableLogging: true,
);

The Android emulator reaches your computer at 10.0.2.2; the iOS Simulator shares its localhost. Plain HTTP needs android:usesCleartextTraffic="true" in the debug manifest on Android and NSAllowsLocalNetworking under NSAppTransportSecurity on iOS. Locally, link URLs on localhost, 127.0.0.1 or 10.0.2.2 resolve as acme.golander.link.

Run the example app:

cd sdk/lander_flutter/example   # inside the Lander monorepo
flutter run

Point it at another API with --dart-define=LANDER_BASE_URL=https://api.golander.link.

Next steps

On this page