Testing

How to widget-test screens that navigate and use stores, with the fakes from package:inlay/testing.dart.

Screens in an add-to-app module rely on two singletons that talk to the host over platform channels: InlayNavigator and KeyValueStorage. In a plain testWidgets run there is no host on the other side, so every call fails with MissingPluginException. package:inlay/testing.dart provides in-memory fakes for both, and both singletons accept a test override.

import 'package:inlay/inlay.dart';
import 'package:inlay/testing.dart';

setUp(() {
  KeyValueStorage.instance = FakeKeyValueStorage();
  InlayNavigator.instance = FakeInlayNavigator();
});

Nothing else about the screen under test changes - it keeps reading KeyValueStorage.instance and calling InlayNavigator.instance.push(...) exactly as in production.

Asserting on navigation

FakeInlayNavigator records every call instead of crossing the boundary:

  • pushedRoutes - typed routes passed to push, in call order.
  • pushedPages - raw PageSettings from all dispatch methods, so native-route, dialog, and pushForResult() pushes are visible too.
  • resultsByRouteId - scripted results for the *ForResult methods, keyed by routeId. Missing entries resolve to null, like a screen dismissed without a result. The generated key is routeName on Flutter routes (the path template) and dialog routes (the camel-cased class name); native routes expose it as routeId.
  • popCount - container pops; maybePop still pops an in-Flutter route when it can.
testWidgets('awaits the Counter result and shows it', (tester) async {
  final navigator = FakeInlayNavigator();
  InlayNavigator.instance = navigator;
  // Script the typed result the fake hands back to pushForResult().
  navigator.resultsByRouteId[CounterPage.routeName] =
      CounterPage.encodeResult(7);

  await tester.pumpWidget(
    const MaterialApp(
      home: GreetingScreen(name: 'Test', style: GreetingStyle.casual),
    ),
  );
  await tester.pumpAndSettle();

  await tester.tap(find.text('Open Counter (new engine, await result)'));
  await tester.pumpAndSettle();

  expect(navigator.pushedPages.single.routeId, CounterPage.routeName);
  expect(find.text('Counter returned: 7'), findsOneWidget);
});

Because the recorded pages use the generated wire format, assertions can go deeper than just checking that something was pushed - you can decode the recorded PageSettings and assert on the actual params that would have crossed the boundary. The full version of this test lives in example_module/test/greeting_screen_test.dart; it also mocks package_info_plus, which GreetingScreen reads on init (see the last section).

Testing screens that use stores

FakeKeyValueStorage is an in-memory map with the same semantics as the real storage. This includes the part that matters most for correctness: your own writes don't notify your own stream, because the real platform suppresses self-notifications. There are three touch points:

  • data - the raw backing map, for inspection or low-level seeding.
  • Seeding through the generated store - the natural way to arrange state, since it uses the same key encoding as production code.
  • simulateExternalChange(entries) - plays the role of another engine or native code writing to storage: it applies the entries and fires the stream, which is exactly what a cross-engine sync looks like from this isolate.
testWidgets('reacts to a change from native code', (tester) async {
  final storage = FakeKeyValueStorage();
  KeyValueStorage.instance = storage;
  await UserPreferencesStore(storage, userId: '42').setDisplayName('Marcin');

  await tester.pumpWidget(const MaterialApp(home: ProfileScreen(userId: '42')));
  await tester.pumpAndSettle();
  expect(find.text('Display name: Marcin'), findsOneWidget);

  storage.simulateExternalChange([
    StorageEntry(key: 'user_preferences/42/displayName', value: 'From native'),
  ]);
  await tester.pumpAndSettle();
  expect(find.text('Display name: From native'), findsOneWidget);
});

This test covers the whole reactive chain (store, InlayCubit, widget rebuild) without a platform on either end.

What the fakes don't cover

Plugins are a separate concern - a screen calling package_info_plus or any other plugin still needs that plugin's own test hooks (PackageInfo.setMockInitialValues(...) and friends). Widget tests also can't tell you whether the native side decodes your routes correctly. For that you need a thin native UI test that crosses into a Flutter screen - see Plugins in a multi-engine setup for the pattern. The example module ships both kinds of tests: widget tests in example_module/test/, and native UI tests in both hosts (XCUITests in the iOS host and UiAutomator tests in the Android host).

On this page