Image credits: Google — Flutter

Flutter: Isolating navigation tests using route stubs

Testing navigation by providing a predefined widget as new routes

Ahmad Hamwi
6 min readJul 22, 2023

--

If you’ve been engaged in widget or integration testing for your Flutter apps, chances are you have encountered situations where widgets trigger navigation to other routes, and verifying the correctness of this navigation becomes essential.

However, when navigating to real, fully functional routes, complications may arise due to the dependencies required by those routes’ widgets to function properly. One common approach to handling such cases is by mocking the dependencies of the pushed route and then checking if new widgets have been rendered.

While this approach can yield the desired results, in a production app, certain routes might have numerous dependencies, explicitly passed in, injected, or inherited from higher-level widgets (for instance, when using the Provider package).

This can lead to writing a considerable amount of boilerplate code for mocks and fakes in your tests, which may not be ideal. To overcome this challenge, there’s a simple yet effective solution: stubbing the newly pushed route’s widget.

Throughout this article, we will explore what route stubbing means and demonstrate the example using the default navigation system in Flutter, specifically utilizing Navigator and onGenerateRoute. Nevertheless, the solution can be adapted to suit your preferred method of navigation with minimal adjustments.

Let’s take a closer look into an example I’ve written:

Say you have a login screen which pushes a homepage route if you’re authenticated, and you’re testing the functionality of authentication and its side effects such as navigation to a home page.
For the sake of simplicity, we’ll be having a login button, which pushes a home page route. Our pages are going to be looking like this:

Here’s the code for the Login page:

class LoginPage extends StatelessWidget {
static const Key authButtonKey = Key("authenticate-button");

const LoginPage({Key? key}) : super(key: key);

void _navigateToHome(BuildContext context) {
Navigator.of(context).pushNamedAndRemoveUntil(
Routing.homeRoute,
(route) => false,
arguments: "This is a text passed as an argument as a demo.",
);
}

@override
Widget build(BuildContext context) {
...
ElevatedButton(
key: authButtonKey,
onPressed: () => _navigateToHome(context),
child: const Text("Authenticate"),
)
...
}
}

The home page will be depending on some dependencies:

  • HomeRemoteDataSource: Providing remote data for the page, and will be injected into the widget through the constructor.
  • AnalyticsService: Providing event logging for the page, and will be accessed from a container.
  • ErrorReportingService: Providing error reporting for the page, and will be accessed from a container.

Here’s the code for the dependencies, they’re written in a way to mimic a real app:

class HomeRemoteDataSource {
Future<String> getData() async {
await Future.delayed(const Duration(seconds: 2));
return "Home data from the HomeDataSource!";
}
}

class ErrorReportingService {
Future<void> reportError(dynamic e) async {
// reports error
}
}

class AnalyticsService {
Future<void> logEvent(String name) async {
// logs event
}
}

The Home page starts fetching its data and updates the state of the homepage as the data is being fetched, here’s the code of the Home page:

class HomePage extends StatefulWidget {
final String textArgument;
final HomeRemoteDataSource homeDataSource;

const HomePage({
Key? key,
required this.textArgument,
required this.homeDataSource,
}) : super(key: key);

@override
State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
String? data;

Future<void> _fetchData() async {
setState(() => data = "Fetching...");

try {
String fetchedData = await widget.homeDataSource.getData();

setState(() => data = fetchedData);
} catch (e) {
sl<ErrorReportingService>().reportError(e);

setState(() => data = "Oops, something went wrong");
}
}

@override
void initState() {
sl<AnalyticsService>().logEvent("home_page_entered");

_fetchData();

super.initState();
}

@override
Widget build(BuildContext context) {
...
children: [
Text(widget.textArgument),
Text(data ?? "No data fetched yet"),
]
...
}
}

Here’s the route-generating logic that I’ve provided to my MaterialApp at the root of my app, it’s written in a way that allows one to map the route with its corresponding widget:

class Routing {
static const String loginRoute = "/";
static const String homeRoute = "/home";

static Map<String, Widget Function(RouteSettings)> routeToWidgetMappings = {
loginRoute: (settings) => const LoginPage(),
homeRoute: (settings) => HomePage(
textArgument: settings.arguments as String,
homeDataSource: sl<HomeRemoteDataSource>(),
),
};

static Widget getWidgetOfRoute(RouteSettings settings) {
final routeName = settings.name;
final widgetFunction = routeToWidgetMappings[routeName];
assert(
widgetFunction != null,
"No route to widget mapping found for route \"${settings.name}\", make sure you have registered the widget as you're adding a new route.",
);
return widgetFunction!(settings);
}

static PageRoute onGenerateRoute(RouteSettings settings) {
return MaterialPageRoute(
builder: (context) => getWidgetOfRoute(settings),
settings: settings,
);
}
}

This will come in handy when we’re testing since we’ll be reusing this logic in our tests.

The default approach: Mocking or faking dependencies to test navigation

Widget or integration testing of the navigation on a real instance of a HomePage will work, but you’ll probably be ending up writing this kind of code:

class FakeHomeRemoteDataSource extends Fake implements HomeRemoteDataSource {
@override
Future<String> getData() async {
return "";
}
}

class FakeErrorReportingService extends Fake implements ErrorReportingService {
@override
Future<void> reportError(e) async {}
}

class FakeAnalyticsService extends Fake implements AnalyticsService {
@override
Future<void> logEvent(String name) async {}
}

void registerFakeDependencies() {
final sl = GetIt.instance;
sl.registerLazySingleton<HomeRemoteDataSource>(
() => FakeHomeRemoteDataSource(),
);
sl.registerLazySingleton<ErrorReportingService>(
() => FakeErrorReportingService(),
);
sl.registerLazySingleton<AnalyticsService>(
() => FakeAnalyticsService(),
);
}

void main() {
registerFakeDependencies();
testWidgets("Authenticates from login to home page", (tester) async {
await tester.pumpWidget(const MaterialApp(
onGenerateRoute: Routing.onGenerateRoute,
home: LoginPage(),
));
await tester.tap(find.byKey(LoginPage.authButtonKey));
await tester.pumpAndSettle();
expect(find.byType(HomePage), findsOneWidget);
});
}

There are a couple of points that need to be addressed here:

  • We’re being forced to fake or mock the dependencies of the Home page so that it doesn’t crash or fail the test.
  • Doing any navigation test will make us end up writing this amount of code, and that’s just for 3 dependencies with their simple usage on the page. It’s a lot of work if we’re maintaining a production app over time as the page gets refactored and requirements change.

The shortcut approach: Stubbing the widgets of the routes

Stubbing routes means providing a predefined response (a widget) to the specific method call (pushing the route) during testing. This allows you to keep your navigation graph and logic in your app as is, while still testing their navigation properly.

When we only care about the correctness of the navigation at the end of your test, and nothing else in the newly pushed route, then it’s a very good idea to stub the widget of the new route, and this can be done by telling the route generator to what widget to use when you’re testing, like so:

void main() {
// No longer needed when stubbing routes
// registerFakeDependencies();

testWidgets("Authenticates from login to home page", (tester) async {
await tester.pumpWidget(
MaterialApp(
onGenerateRoute: (routeSettings) {
return TestRouting.onGenerateRoute(
settings: routeSettings,
routeStubbingOptions: {Routing.homeRoute: true},
);
},
home: const LoginPage(),
),
);
await tester.tap(find.byKey(LoginPage.authButtonKey));
await tester.pumpAndSettle();
expect(find.byKey(const Key(Routing.homeRoute)), findsOneWidget);
});
}

TestRouting is a class I’ve placed in the test directory, it’s meant to provide a testing implementation of the route generation logic, that’s why it accepts a routeStubbingOptions object, which is a map from a route name to a flag indicating if the route should be stubbed or not. By flagging what routes need to be stubbed or not, and replacing the flagged routes with a stub widget (such as a Container, a Placeholder, etc…), we can simply let a lightweight widget act as the homepage, without the need to deal with real instances of widgets or their dependencies. Here’s the TestRouting implementation:

/// A map from a route name to a flag indicating if the route should stubbed
/// or not.
typedef RouteStubbingOptions = Map<String, bool>;

class TestRouting {
static PageRoute onGenerateRoute({
required RouteSettings settings,
required RouteStubbingOptions routeStubbingOptions,
}) {
final routeName = settings.name;
return MaterialPageRoute(
builder: (_) {
final routeStubbingFlag = routeStubbingOptions[routeName] ?? false;

Key key = Key(settings.name!);

return routeStubbingFlag
? Placeholder(key: key)
: Routing.getWidgetOfRoute(settings);
},
settings: settings,
);
}
}

As you can see, we’re reusing the logic of the real onGenerateRoute by accessing Routing.getWidgetOfRoute. I’ve set the default behaviour of not providing a stubbing flag of a route to mean that it should not stub the route.

One note is that we’re finding the new route by key, which we definied in the TestRouting’s onGenerateRoute, since we no longer able to find the widget by type.

--

--