I love automated testing.

When done correctly, it saves time spent on bug hunting and helps you avoid nasty regressions. If done before any manual testing, it saves time implementing features too. That all being said, navigation tests are not going to be first on the list of important things to test. However, a question about testing navigation came up in the Slack group. As someone who did a couple of regression tests for navigation before, I figured out it’s worth to share.

Our sample app consists of two pages: a MainPage and a DetailsPage. From the main page, the user can click a button, and he ends up in the details page. The details page receives a friendly greeting in its constructor parameter.

lib/main_page.dart
import 'details_page.dart';

class MainPage extends StatelessWidget {
  void _navigateToDetailsPage(BuildContext context) {
    final route = MaterialPageRoute(builder: (_) => DetailsPage('Hello!'));
    Navigator.of(context).push(route);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Testing navigation'),
      ),
      body: RaisedButton(
        onPressed: () => _navigateToDetailsPage(context),
        child: Text('Navigate to details page!'),
      ),
    );
  }
}

The details page is a StatelessWidget displaying a simple centered text. It takes in the message from the main page and then displays it in a Text widget.

lib/details_page.dart

class DetailsPage extends StatelessWidget {
  DetailsPage(this.message);
  final String message;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Details page'),
      ),
      body: Center(
        child: Text(message),
      ),
    );
  }
}

Quite something, isn’t it? Our app is going to be the next Facebook. You wait and see.

How do we test this thing?

To make things easier, we’ll use a Key to get the reference to the “Navigate to details page!” button. And yes, we could find the button by its text without using any keys. However, I generally prefer using keys to make things more foolproof.

If the app is localized in different languages, finding things by their text contents could become quite unpredictable. For example, “Navigate to details page” becomes “Avaa tietosivu” in Finnish. Now we would have to lock our test app to a single language and cross our fingers that nobody messes up the translation files. And since that special someone will be ourselves doing yet another late Friday production release, crossing our fingers won’t help.

Since we’re now convinced that keys are the way to go, we’ll define a new key and assign it to the button by passing it as the key constructor parameter.

lib/main_page.dart

class MainPage extends StatelessWidget {
  // 1: Define the key here...
  static const navigateToDetailsButtonKey = Key('navigateToDetails');

  // ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // ...
      body: RaisedButton(
        // 2: ...and assign it to the RaisedButton here.
        key: navigateToDetailsButtonKey,
        // ...
      ),
    );
  }
}

Keys are not special to only RaisedButtons - pretty much all the widgets in Flutter have a key parameter in their constructor. And I say “pretty much all” only because I think every widget does but I’m not 100% sure. It’s getting late, and I don’t have the time go through all of them.

With navigateToDetailsButtonKey available as a static field, our test cases can now easily identify the right thing to tap on the screen. To observe navigator events and verify they happen correctly, we use the mockito package in conjunction with a NavigatorObserver. In short, we’ll pass a mocked navigation observer to our MaterialApp to verify various navigation events.

test/navigation_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';

import 'main_page.dart';

class MockNavigatorObserver extends Mock implements NavigatorObserver {}

void main() {
  group('MainPage navigation tests', () {
    late NavigatorObserver mockObserver;

    setUp(() {
      mockObserver = MockNavigatorObserver();
    });

    Future<void> _buildMainPage(WidgetTester tester) async {
      await tester.pumpWidget(MaterialApp(
        home: MainPage(),

        // This mocked observer will now receive all navigation events
        // that happen in our app.
        navigatorObservers: [mockObserver],
      ));

      // The tester.pumpWidget() call above just built our app widget
      // and triggered the pushObserver method on the mockObserver once.
      verify(mockObserver.didPush(any!, any));
    }

    Future<void> _navigateToDetailsPage(WidgetTester tester) async {
      // Tap the button which should navigate to the details page.
      //
      // By calling tester.pumpAndSettle(), we ensure that all animations
      // have completed before we continue further.
      await tester.tap(find.byKey(MainPage.navigateToDetailsButtonKey));
      await tester.pumpAndSettle();
    }

    testWidgets(
        'when tapping "navigate to details" button, should navigate to details page',
        (WidgetTester tester) async {
      // TODO: Write the test case here.
    });
  });
}

A good convention is to name the mocks with a Mock prefix. Since we’re mocking a NavigatorObserver class, we call our mock version of that MockNavigatorObserver.

Testing pushed Routes

There are three simple steps for testing that the details page route is pushed.

  1. Build the MainPage so that it’s available for testing.
  2. Find and tap the button.
  3. Verify that we’re now in the details page.

Since we created utility methods for building the page and tapping button previously, our test code becomes easy to follow.

test/navigation_test.dart
void main() {
  group('MainPage navigation tests', () {
    // ...
    testWidgets(
        'when tapping "navigate to details" button, should navigate to details page',
        (WidgetTester tester) async {
      await _buildMainPage(tester);
      await _navigateToDetailsPage(tester);

      // By tapping the button, we should've now navigated to the details
      // page. The didPush() method should've been called...
      verify(mockObserver.didPush(any!, any));

      // ...and there should be a DetailsPage present in the widget tree...
      expect(find.byType(DetailsPage), findsOneWidget);

      // ...with the message we sent from main page.
      expect(find.text('Hello!'), findsOneWidget);
    });
  });
}

Now our test code verifies that by pressing the button, a new route is pushed and that a DetailsPage exists in the widget tree. We could get rid of the mockObserver and that verify call altogether, but that would make our test less deterministic.

Without the navigation observer, our test would still verify that after pressing the button a DetailsPage exists in the widget tree, but it wouldn’t make sure that a new route was pushed. Pushing the button might replace the body of the main Scaffold instead of pushing a new route. The test would pass, but the behavior wouldn’t be what we want.

Sidenote: stricter finders

It’s worth noting here that the find.text('Hello!') expectation could be more specific. To have a stricter expectation, we could do this:

test/navigation_test.dart
// ...
var detailsFinder = find.byType(DetailsPage);
expect(detailsFinder, findsOneWidget);

var messageFinder = find.text('Hello!');
var strictMatch = find.descendant(of: detailsFinder, matching: messageFinder);
expect(strictMatch, findsOneWidget);

We still use the same finder for the Hello! message, but now we have another finder in place. By using find.descendant, we make sure that the text Hello belongs to a DetailsPage widget. In this case, I think it it’s not needed though.

Testing popped Routes with results

In the previous example, our details page was quite simple - it only contained a text widget. However, what if the details page passes some result when popping itself? In a real world, such a use case would be a login screen that pops with a result once the login process is done. In our app, we’ll return something simple and static.

lib/details_page.dart
class DetailsPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // ...
      body: RaisedButton(
        onPressed: () => Navigator.pop(context, 'I\'m a pirate.'),
        child: Text('Click me!'),
      ),
    );
  }
}

By clicking the button, we call Navigator.pop which closes the details page. Since we also pass a result parameter to the pop method, whoever invoked Navigator.push() on the details route will now receive the result. In this case, the main page receives a result of “I’m a pirate.”.

To test this, we’ll first want to create a Key for the button, which we’ll call popWithResultButtonKey.

lib/details_page.dart
class DetailsPage extends StatelessWidget {
  // 1: Define the key here...
  static const popWithResultButtonKey = Key('popWithResult');

  // ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // ...
      body: RaisedButton(
        // 2: ...and assign it to the RaisedButton here.
        key: popWithResultButtonKey,
        // ...
      ),
    );
  }
}

Now we can find the button by its Key and start writing a pop test.

To get a reference to the newly pushed route, we capture it in our verify method call by using the captureAny matcher from mockito. This way, we can call .captured.single after the verify method to get a reference to the newly pushed route. Then we’ll assign that to a variable that we’ll call pushedRoute.

final Route? pushedRoute =
    verify(mockObserver.didPush(captureAny!, any))
        .captured
        .single;

Now that we have a reference to that route, we’ll also want to find out the pop result when the route gets popped. Upon inspecting the documentation by performing a Cmd+Click on the Route class in our IDE, we can see that a route has a getter called popped.

/// A future that completes when this route is popped off the navigator.
///
/// The future completes with the value given to [Navigator.pop], if any.
Future<T> get popped => _popCompleter.future.then((value) => value as T);

This fits perfectly for our use case. Since the return type is a Future, we can call .then() on it. The callback will be invoked once the future is complete. We can declare an empty variable and populate that with the pop result once the future resolves.

(In case you’re wondering, we can’t await a Future from the Flutter framework in tests. The future will never resolve and we’ll get a time out exception.)

After setting that up, we tap on the button on the DetailsPage, call tester.pumpAndSettle() to make sure that the route pops completely, and then expect the pop result to be I'm a pirate..

test/navigation_test.dart
void main() {
  group('MainPage navigation tests', () {
    // ...
    testWidgets('tapping "click me" should pop with a result',
        (WidgetTester tester) async {
      // We'll build the main page and navigate to details first.
      await _buildMainPage(tester);
      await _navigateToDetailsPage(tester);

      // Then we'll verify that the details route was pushed again, but
      // this time, we'll capture the pushed route.
      final Route pushedRoute =
          verify(mockObserver.didPush(captureAny!, any)).captured.single;

      // We declare a popResult variable and assign the result to it
      // when the details route is popped.
      String? popResult;
      pushedRoute.popped.then((result) => popResult = result);

      // Pop the details route with a result by tapping the button.
      await tester.tap(find.byKey(DetailsPage.popWithResultButtonKey));
      await tester.pumpAndSettle();

      // popResult should now contain whatever the details page sent when
      // calling `Navigator.pop()`. In this case, "I'm a pirate".
      expect(popResult, 'I\'m a pirate.');
    });
  });
}

And that’s it - now we’ve tested both pushing and popping routes with extra data sent to both directions.

How to test API-dependent Routes?

At this point, you might say that in the real world things are never this simple. And you’re right. Testing navigation in real apps that make network requests is a topic of its own.

In an ideal world, you don’t want to deal with any (even mocked) API clients at all. In this case, EventsPage is just a dumb StatelessWidget that takes in a view model. That view model contains all the information for the EventsPage to know how to render itself. If something needs to be updated, a new view model is built and then passed to the EventsPage, causing it to update itself.

Here’s a sample test case from my Redux-based app, inKino:

inKino tests: events_page_test.dart
class MockEventsPageViewModel extends Mock implements EventsPageViewModel {}

class MockNavigatorObserver extends Mock implements NavigatorObserver {}

void main() {
  group('EventsPage tests', () {
    late MockNavigatorObserver observer;
    EventsPageViewModel? mockViewModel;

    setUp(() {
      observer = MockNavigatorObserver();
      mockViewModel = MockEventsPageViewModel();
    });

    Future<void> _buildEventsPage(WidgetTester tester) {
      return tester.pumpWidget(MaterialApp(
        home: EventsPage(mockViewModel),
        navigatorObservers: <NavigatorObserver>[observer],
      ));
    }

    testWidgets(
      'when tapping on an event poster, should navigate to event details',
      (WidgetTester tester) async {
        final List<Event> events = [Event(title: 'Test Title')];
        when(mockViewModel!.status).thenReturn(LoadingStatus.success);
        when(mockViewModel!.events).thenReturn(events);

        await _buildEventsPage(tester);

        // Building the events page should trigger the navigator observer
        // once.
        verify(observer.didPush(any!, any));

        await tester.tap(find.text('Test Title'));
        await tester.pumpAndSettle();

        verify(observer.didPush(any!, any));
        expect(find.byType(EventDetailsPage), findsOneWidget);
      },
    );
  });
}

As we can see, stateless widgets are quite easy to test.

We can pass a view model in any needed state and verify whatever we need. In this example, I didn’t verify that the details page contains anything - if it’s present in the widget tree, I’m all good. However, I could test that there’s now some additional text present by calling expect(find.text('Some details'), findsOneWidget) after pushing the details page.

To see some real-world navigation and lots of other tests, go and check out my inKino app on GitHub. I use it as a testing reference all the time.

The end.

I left out some code from the article for clarity. To get the full picture, see the complete sample app right here.