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 line | Meaning |
|---|---|
Created install ID ... | First launch (or a reset install). A new random ID was stored. |
Init ok | The 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 events | A batch of events was accepted. |
Event batch failed (network_error), retrying in 2s | The 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 one | You switched keys between test and live (or projects). Harmless. |
Open links from the terminal
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 # AndroidWays 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 callLander.claimHandoff. See Authenticated handoff. - iOS Continue in app page (real device, with
iosLandingPageon): 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 runand 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:
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:
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
}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:
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 runPoint it at another API with --dart-define=LANDER_BASE_URL=https://api.golander.link.