Skip to content
DeveloperMemos

Mocking Navigator in testWidgets

Dart, Flutter, Unit Tests, Flutter Tips2 min read

When I try to get to 100% coverage in my unit tests and I'm writing tests with testWidgets sometimes I need to test some navigation logic. There are a couple of different ways to do this but here's one particular solution that I've settled on.

Dependencies

It's not rocket science but I thought it might be worth writing a quick article on. I use mockito to mock the NavigatorObserver class so first of all you'll have to add mockito to your pubspec.yaml in your dev dependencies. You'll also need to add build_runner too.

Step 1: @GenerateMocks

The mockito package uses an annotation called 'GenerateMocks' to generate mocks since null safety landed. So first of all you need to create your test file and annotate main with @GenerateMocks, and add NavigatorObserver in customMocks:

1import 'package:flutter/material.dart';
2import 'package:flutter_test/flutter_test.dart';
3import 'package:mockito/annotations.dart';
4
5@GenerateMocks(
6 [],
7 customMocks: [
8 MockSpec<NavigatorObserver>(
9 returnNullOnMissingStub: true,
10 )
11 ],
12)
13void main() {
14
15}

After this you need to run flutter pub run build_runner build to generate the mocks. Once the mocks are generated you need to import them(the file should end with *.mocks.dart):

1import 'package:flutter/material.dart';
2import 'package:flutter_test/flutter_test.dart';
3import 'package:mockito/annotations.dart';
4// Add this:
5import '[your file name].mocks.dart';

If your test file is called 'route_navigation_test.dart' you would end up with this:

1import 'package:flutter/material.dart';
2import 'package:flutter_test/flutter_test.dart';
3import 'package:mockito/annotations.dart';
4// Add this:
5import 'route_navigation_test.mocks.dart';

And now you can create an instance of MockNavigatorObserver and also add an empty test, here's the full code so far:

1import 'package:flutter/material.dart';
2import 'package:flutter_test/flutter_test.dart';
3import 'package:mockito/annotations.dart';
4
5import 'route_navigation_test.mocks.dart';
6
7@GenerateMocks(
8 [],
9 customMocks: [
10 MockSpec<NavigatorObserver>(
11 returnNullOnMissingStub: true,
12 )
13 ],
14)
15void main() {
16 final observerMock = MockNavigatorObserver();
17
18 testWidgets('Make sure a push occurs', (tester) async {});
19}

Step 2: Write the test

Okay now it's time to write the actual test, first of all we need to use tester's pumpWidget method. We're going to pump a MaterialApp that has two routes and an initialRoute specified('/home' and '/settings'), and also slot observerMock into navigatorObservers:

1testWidgets('Make sure a push occurs', (tester) async {
2 await tester.pumpWidget(
3 MaterialApp(
4 initialRoute: '/home',
5 routes: {
6 '/home': (context) => Scaffold(
7 body: ElevatedButton(
8 onPressed: () => Navigator.of(context).pushNamed('/settings'),
9 child: const Text('To Settings'),
10 ),
11 ),
12 '/settings': (_) => const Scaffold(
13 body: Text("Settings"),
14 ),
15 },
16 navigatorObservers: [
17 observerMock,
18 ],
19 ),
20 );
21});

I've simplified everything to make things more concise, in reality you would probably have two widgets called 'HomePage' and 'SettingsPage' instead of Scaffolds. When you run this test it will pass because you have no verify or expect calls yet.

As you might have noticed the '/home' route has an ElevatedButton that pushes to '/settings', so first of all we're going to instruct tester to tap that button:

1await tester.tap(find.byType(ElevatedButton));

And now all that's left to do is verify that a push has occured using the verify method from mockito:

1verify(observerMock.didPush(any, any));

When you run the test now it should pass.

The full code

Here's the full code for the above test:

1import 'package:flutter/material.dart';
2import 'package:flutter_test/flutter_test.dart';
3import 'package:mockito/annotations.dart';
4import 'package:mockito/mockito.dart';
5
6import 'route_navigation_test.mocks.dart';
7
8@GenerateMocks(
9 [],
10 customMocks: [
11 MockSpec<NavigatorObserver>(
12 returnNullOnMissingStub: true,
13 )
14 ],
15)
16void main() {
17 final observerMock = MockNavigatorObserver();
18
19 testWidgets('Make sure a push occurs', (tester) async {
20 await tester.pumpWidget(
21 MaterialApp(
22 initialRoute: '/home',
23 routes: {
24 '/home': (context) => Scaffold(
25 body: ElevatedButton(
26 onPressed: () => Navigator.of(context).pushNamed('/settings'),
27 child: const Text('To Settings'),
28 ),
29 ),
30 '/settings': (_) => const Scaffold(
31 body: Text("Settings"),
32 ),
33 },
34 navigatorObservers: [
35 observerMock,
36 ],
37 ),
38 );
39
40 await tester.tap(find.byType(ElevatedButton));
41 verify(observerMock.didPush(any, any));
42 });
43}

Caveats/discussion

You can also use this same logic for pop calls, you just need to verify didPop instead of didPush. You might have noticed that I used mockito's any for both arguments of didPush. This might not be sufficient if you are testing multiple routes, so you can pass arguments instead but the code will get a little more verbose in that situation.

You also might wonder why I used customMocks for @GenerateMocks, this is really just a matter of conciseness. You don't need to declare NavigatorObserver in customMocks but if you don't you will need to specific the mocking logic for the methods that you plan on using with mockito's when and thenAnswer(or the test will fail).