Mocking Flutter routes with Mockingjay

Why and how to mock routes when testing your Flutter code

August 18, 2021
ByΒ 
andΒ 
August 18, 2021
updated on
March 13, 2023
ByΒ 
Guest Contributor

Testing your application's code is important. At Very Good Ventures, this sentiment has been with us from the very beginning, and we have explained our approach to testing multiple times. We use a myriad of metrics and methods to give us a level of confidence that the code we write works the way we want to -- not just now, but especially when we need to go back and change bits of a particular feature in the future. We want to be sure that we catch bugs that we could have prevented well in advance, before they hit the app stores or, better yet, before they are even merged into our codebase.

Throughout the years, we have established and battle-tested methods of testing multiple aspects of Flutter development; we use bloc_test to assert the logic in our blocs works as expected, and the mocktail package helps us mock anything related to APIs, repositories and data models.

One facet that has been proving more difficult to test than others for a long time, however, has been navigation routes. We want to be sure that the buttons and logic in our app route to the correct screens and dialogs -- in fact, our 100% code coverage requirement forces us to test every executable line in our codebase, including navigation calls. However, when testing navigation routes, some interesting issues can occur.

Great news! We have built the newly released πŸ•Š Mockingjay package to help with exactly that. In this article, we go over the issues we've found when testing routes in Flutter apps, and how Mockingjay helps us more easily test and verify any routing logic.

Note, this article assumes you already know the basics of the bloc state management patterns and Flutter testing. If you want to catch up on these topics, we recommend having a look at these articles: Flutter Testing: A Very Good Guide and Why We Use flutter_bloc for State Management.

Testing routes today

For us, testing UI in a Flutter app comes with multiple goals in mind. Among other things, a widget test should be;

  • atomic; it should only test the part of the application the test is focussed on, and mock any external dependencies.
  • exhaustive; we should easily be able to test all parts of the UI, including which widgets are rendered for a given state and what actions occur when pressing any button.
  • repeatable; we want to have a consistent approach for how we build both the UI itself as well as the tests we write for it.

An example

As mentioned before, most aspects of widget tests already have well-established patterns we follow. For example, take a chat application that has a page with a list of friends that you can chat with. This chat overview page might have a button at the top right that will take you to your profile settings. Using the blocs and repositories pattern, as we do for every project, the code for this ChatOverviewPage might look something like this.


class ChatOverviewPage extends StatelessWidget {
  const ChatOverviewPage({Key? key}) : super(key: key);
  static Route route() {
    return MaterialPageRoute(
      builder: (context) {
        return BlocProvider(
          create: (context) => ChatBloc(
            chatRepository: context.read<ChatRepository>(),
          ),
          child: const ChatOverviewPage(),
        );
      },
    );
  }
  @override
  Widget build(BuildContext context) {
    final state = context.watch<ChatBloc>().state;
    return Scaffold(
      appBar: AppBar(
        title: const Text('Chats'),
        actions: [
          IconButton(
            onPressed: () {
              Navigator.of(context).push(UserProfileSettingsPage.route());
            },
            icon: const Icon(Icons.settings),
          ),
        ],
      ),
      body: ListView(
        children: [
          for (final conversation in state.conversations)
            ChatConversationListTile(
              conversation: conversation,
            ),
        ],
      ),
    );
  }
}

There are a few things to note about this screen. As you can tell, the ChatOverviewPage has a single dependency on a ChatBloc, which in turn depends on a ChatRepository. This is a common pattern we see throughout our codebases and helps create applications that can scale to virtually any size and complexity. This means that, in our tests, we can simply mock the ChatRepository when testing the route() method. For the rest of the UI, all we have to do is provide a mock instance of a ChatBloc. With that, all dependencies on this screen are mocked, and every aspect of the screen can be tested!

Additionally, a button is displayed in the AppBar that routes to a UserProfileSettingsPage. Let's have a look at what its source code might look like.


class UserProfileSettingsPage extends StatelessWidget {
  const UserProfileSettingsPage({Key? key}) : super(key: key);
  static Route route() {
    return MaterialPageRoute(
      builder: (context) {
        return BlocProvider(
          create: (context) => UserSettingsBloc(
            userSettingsRepository: context.read<UserSettingsRepository>(),
          ),
          child: const UserProfileSettingsPage(),
        );
      },
    );
  }
  @override
  Widget build(BuildContext context) {
    final state = context.watch<UserSettingsBloc>().state;
    return Scaffold(
      appBar: AppBar(
        title: const Text('Profile Settings'),
      ),
      body: ListView(
        children: [
          ListTile(
            title: const Text('Status'),
            subtitle: Text(state.settings.isOnline ? 'Online' : 'Offline'),
          ),
          ListTile(
            title: const Text('Phone number'),
            subtitle: Text(state.settings.phoneNumber),
          ),
          ListTile(
            title: const Text('Email address'),
            subtitle: Text(state.settings.emailAddress),
          ),
        ],
      ),
    );
  }
}

Similar to the ChatOverviewPage, this UserProfileSettingsPage has a single dependency on a UserSettingsBloc, which in turn depends on a UserSettingsRepository. The testing setup for this screen will be almost identical to the one for ChatOverviewPage; a mock can be made for both the repository and the bloc, and we can test the route() and build() methods respectively.

Let's get testing

Let's have a look at what our test file for the ChatOverviewPage would look like, starting with some general setup. We first declare some mocks for our repository and bloc, create some fake data to drive the UI, and make sure this data is visible to the UI whenever it tries to fetch the nearest bloc.


// import '...';
import 'package:mocktail/mocktail.dart';
class MockChatRepository extends Mock implements ChatRepository {}
class MockChatBloc extends MockBloc<ChatEvent, ChatState> implements ChatBloc {}
void main() {
  group('ChatOverviewPage', () {
    late ChatRepository chatRepository;
    late ChatBloc chatBloc;
    const conversations = [
      Conversation(
        chatName: 'mock-conversation-1',
        unreadMessages: 0,
      ),
      Conversation(
        chatName: 'mock-conversation-2',
        unreadMessages: 5,
      ),
      Conversation(
        chatName: 'mock-conversation-3',
        unreadMessages: 10,
      ),
    ];
    setUpAll(() {
      registerFallbackValue(const ChatState());
      registerFallbackValue(const ChatEvent());
    });
    setUp(() {
      chatRepository = MockChatRepository();
      when(() => chatRepository.getConversations()).thenAnswer((_) async => []);
      chatBloc = MockChatBloc();
      when(() => chatBloc.state).thenReturn(const ChatState(
        conversations: conversations,
      ));
    });
    // ...
  });
  // ...
}

Now that we're all set up, let's create a simple test for the route() method.


testWidgets('route method creates route normally', (tester) async {
  await tester.pumpWidget(
    RepositoryProvider.value(
      value: chatRepository,
      child: MaterialApp(
        onGenerateRoute: (_) => ChatOverviewPage.route(),
      ),
    ),
  );
  expect(find.byType(ChatOverviewPage), findsOneWidget);
  expect(tester.takeException(), isNull);
});

Looks great! A mock instance of the ChatRepository is provided, and some clever usage of the MaterialApp's internal Navigator allows us to call the route() method to make sure it functions as intended. A ChatOverviewPage is rendered, and no exceptions occurred during the creation of the ChatBloc.

Next up is the list of ChatConversationListTiles present in the actual view, one of which should be rendered for each conversation. Since we've already made sure that the ChatBloc can be successfully constructed by testing the route() method, we can simply provide a mock ChatBloc for all our remaining tests.


testWidgets(
  'renders a ChatConversationListTile for each conversation',
  (tester) async {
    await tester.pumpWidget(
      BlocProvider.value(
        value: chatBloc,
        child: const MaterialApp(
          home: ChatOverviewPage(),
        ),
      ),
    );
    expect(find.byType(ChatConversationListTile), findsNWidgets(3));
  },
);

Simple, yet effective. In our setup, we made sure the chatBloc would return a ChatState with three Conversation objects, so that is what we expect to find in the UI when counting the number ChatConversationListTiles rendered.

Now, let's test the profile settings button to make sure it routes correctly.


testWidgets(
  'routes to UserProfileSettingsPage when settings button is pressed',
  (tester) async {
    await tester.pumpWidget(
      BlocProvider.value(
        value: chatBloc,
        child: const MaterialApp(
          home: ChatOverviewPage(),
        ),
      ),
    );
    await tester.tap(find.byIcon(Icons.settings));
    await tester.pumpAndSettle();
    expect(find.byType(UserProfileSettingsPage), findsOneWidget);
  },
);

Nice! Let's give it a spin and...


══║ EXCEPTION CAUGHT BY WIDGETS LIBRARY β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
The following ProviderNotFoundException was thrown building UserProfileSettingsPage(dirty,
dependencies: [_InheritedProviderScope<UserSettingsBloc>]):
Error: Could not find the correct Provider<UserSettingsRepository>=> above this
_InheritedProviderScope<UserSettingsBloc> Widget
This happens because you used a `BuildContext` that does not include the provider
of your choice.

Oh boy, that's a problem.

Why this approach fails

Perhaps you've already figured out what the problem is in our last test. As mentioned before, our ChatOverviewPage depends on a ChatBloc and ChatRepository, which we've successfully mocked as shown in our previous tests. However, in writing our tests, we forgot the fact that the UserProfileSettingsPage also has two dependencies, those being the UserSettingsBloc and UserSettingsRepository. In our last test, we press the button that executes the UserProfileSettingsPage.route() method. This method requires a UserSettingsRepository to construct the UserSettingsBloc. This means that, only for this specific test, we would need to provide a mock UserSettingsRepository in addition to the chat-related dependencies. That would make the test look something like this:


// ...
class MockUserSettingsRepository
  extends Mock
  implements UserSettingsRepository {}
void main() {
  group('ChatOverviewPage', () {
    // ...
  late UserSettingsRepository userSettingsRepository;
    // ...
    setUp(() {
      // ...
      userSettingsRepository = MockUserSettingsRepository();
      when(() => userSettingsRepository.getSettings())
        .thenAnswer((_) async => const Settings());
    });
    // ...
    testWidgets(
      'routes to UserProfileSettingsPage when settings button is pressed',
      (tester) async {
        await tester.pumpWidget(
          BlocProvider.value(
            value: chatBloc,
            child: RepositoryProvider.value(
              value: userSettingsRepository,
              child: const MaterialApp(
                home: ChatOverviewPage(),
              ),
            ),
          ),
        );
        await tester.tap(find.byIcon(Icons.settings));
        await tester.pumpAndSettle();
        expect(find.byType(UserProfileSettingsPage), findsOneWidget);
      },
    );
  });
}

Why this is a bad solution

Though the test above works properly, I think it goes without saying it's not the ideal approach for multiple reasons.

  1. The test dedicated to verifying the functionality of the ChatOverviewPage is no longer atomic since we have brought dependencies related to the UserProfileSettingsPage over to this file. This increases the overall noise in the file, increases the line count, and gives the testing logic new responsibilities it shouldn't receive.
  2. Though this testing approach is repeatable in some sense, the simplicity has decreased significantly since it forces us to add all the aforementioned boilerplate to every test related to UI that can route to the UserProfileSettingsPage. In other words, if three pages can route to that screen, we need to duplicate this logic in three different tests in addition to the file for the UserProfileSettingsPage test itself, increasing the amount of noise in our tests every time.

If only there were a way to prevent all this...

Mockingjay to the rescue! πŸŽ‰

Introducing πŸ•Š Mockingjay, a package from Very Good Ventures that helps you test any kind of routing in your Flutter app. This newly released and fully null-safe package (which is completely open source) uses mocktail to allow you to easily mock a Navigator in the same way any model or bloc can be mocked, making for a significantly cleaner and simpler testing approach. One of the best parts is that nothing about your UI code needs to change; it can be used as a dev dependency and is only relevant to your testing files.

Let's try it out

Out of the box, Mockingjay gives you a MockNavigator class. This is a subclass of NavigatorState that gives you the ability to mock and verify any method you would normally call using Navigator.of(context), such as push, pop, and replace. That allows us to completely omit adding extra setup and logic to mock dependencies that aren't related to the bit of UI we're testing.

Let's take our previous tests and make a few additions to the setup. Mockingjay includes the mocktail package, so we can simply replace the import statement, making our tests a little bit smaller. Additionally, we can create a mock navigator instance and give it some default behavior.


// import '...';
import 'package:mockingjay/mockingjay.dart';
// ...
void main() {
  group('ChatOverviewPage', () {
    late ChatRepository chatRepository;
    late ChatBloc chatBloc;
    late MockNavigator navigator;
    // ...
    setUp(() {
      // ...
      navigator = MockNavigator();
      // When calling `Navigator.of(context).push(...)`, return void by default.
      when(() => navigator.push(any())).thenAnswer((_) async {});
    });
    // ...
  });
  // ...
}

Now that we're all set up, we can use the MockNavigatorProvider in our tests to provide this mock navigator to any part of the widget tree, allowing us to mock any navigation call.

Make sure your MockNavigatorProvider is declared below any MaterialApp or Navigator widget to make sure the UI finds the MockNavigator instead of a real NavigatorState.


testWidgets(
  'routes to UserProfileSettingsPage when settings button is pressed',
  (tester) async {
    await tester.pumpWidget(
      BlocProvider.value(
        value: chatBloc,
        child: MaterialApp(
          // Provide the mock navigator to any widget below
          // this point in the widget tree.
          home: MockNavigatorProvider(
            navigator: navigator,
            child: const ChatOverviewPage(),
          ),
        ),
      ),
    );
    await tester.tap(find.byIcon(Icons.settings));
    // Verify a new route was pushed once.
    verify(() => navigator.push(any())).called(1);
  },
);

Beautiful! Our test is a lot more concise and no longer contains dependencies that are unrelated to the ChatOverviewPage.

But wait, there's more!

As you might have figured out already, we now have full control over mocking and verifying the exact types of navigation calls within our app. This means we can be very specific about what we want to return when any method is called on the mock navigator. This gives us a lot more advantages than might appear at face value.

Mockingjay comes with a matcher called isRoute that makes it easy to verify what kind of route was performed, including the return type and even the name it should match.

Say your app contains a dialog box that asks your user if they prefer pizza or hamburgers. A widget like that might look something like this:


enum QuizOption {
  pizza,
  hamburger,
}
class QuizDialog extends StatelessWidget {
  const QuizDialog({Key? key}) : super(key: key);
  static Future<QuizOption?> show(BuildContext context) {
    return showCupertinoDialog<QuizOption>(
      context: context,
      // Important for compatibility with MockNavigator.
      useRootNavigator: false,
      builder: (context) => const QuizDialog(),
    );
  }
  @override
  Widget build(BuildContext context) {
    return CupertinoAlertDialog(
      content: const Text('Which food is the best?'),
      actions: [
        CupertinoDialogAction(
          onPressed: () => Navigator.of(context).pop(QuizOption.pizza),
          child: const Text('πŸ•'),
        ),
        CupertinoDialogAction(
          onPressed: () => Navigator.of(context).pop(QuizOption.hamburger),
          child: const Text('πŸ”'),
        ),
      ],
    );
  }
}

Without Mockingjay, every typical widget test for a page showing this dialog would need to press a button to open the dialog, then find the specific widget needed to provide a return value of either pizza or hamburger, and then verify the rest of the logic. However, using our newly acquired superpowers, a test like this becomes trivial to write.


testWidgets('shows quiz dialog when pressed', (tester) async {
  await tester.pumpWidget(
    MockNavigatorProvider(
      navigator: navigator,
      child: const QuizPage(),
    );
  );
  await tester.tap(find.byKey(showQuizDialogTextButtonKey));
  // Verify a new route of type `QuizOption?` was pushed
  // when the button was pressed.
  verify(() => navigator.push(any(that: isRoute<QuizOption?>())))
      .called(1);
});

Need a specific return value from this route? Easy peasy widget treesy. 😎


testWidgets('displays snackbar when pizza was selected', (tester) async {
  // Whenever a route of type `QuizOption?` is pushed,
  // return `QuizOption.pizza`.
  when(() => navigator.push(any(that: isRoute<QuizOption>())))
      .thenAnswer((_) async => QuizOption.pizza);
  await tester.pumpWidget(
    MockNavigatorProvider(
      navigator: navigator,
      child: const QuizPage(),
    );
  );
  await tester.tap(find.byKey(showQuizDialogTextButtonKey));
  await tester.pumpAndSettle();
  expect(
    find.widgetWithText(SnackBar, 'Pizza all the way! πŸ•'),
    findsOneWidget,
  );
});

Testing proper popping behavior becomes a breeze as well.


void main() {
  group('QuizDialog', () {
    // ...
    testWidgets(
      'pops route with pizza option when pizza button is pressed',
      (tester) async {
        await tester.pumpTest(
          builder: (context) {
            return MockNavigatorProvider(
              navigator: navigator,
              child: const QuizDialog(),
            );
          },
        );
        await tester.tap(find.text('πŸ•'));
        // Verify the current route is popped with the correct
        // return value.
        verify(() => navigator.pop(QuizOption.pizza)).called(1);
      },
    );
  });
}

Try it out!

We're very excited about using this package in our tests. It helps to keep tests simple and to the point, and takes away multiple headaches, while making it easier to reach our goal of 100% code coverage. We'd love to hear what you think about our package. If you're curious how the magic happens, feel free to take a look at the repository. If you like it, be sure to leave a ⭐️! If you have any feature requests or other feedback, feel free to open an issue or pull request.

Thank you for reading, and happy testing! πŸ‘‹πŸ» πŸ§ͺ πŸ§‘β€πŸ”¬

‍

Felix Angelov contributed to this article.

More Stories