— Dart, Flutter, Unit Tests, Flutter Tips — 2 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.
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.
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}
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.
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}
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).