Writing widget tests for navigation events
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.
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.
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.
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.
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.
- Build the
MainPage
so that it’s available for testing. - Find and tap the button.
- 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.
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:
// ...
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.
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
.
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.
.
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:
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.
4 comments
likes