Post intended to describe the idea for those who want to have one more option to mock the Navigator. We can just mock the Navigator itself. How did I achieve it?
The following steps were made by me:
- Introduce the NavigatorAdapter. This logic has nothing special, it just redirects the calls to the actual Navigator instance.
- Using
get_it
package, take the instance of the NavigatorAdapter like this: GetIt.I<NavigatorAdapter>();
. Clearly, this have the drawback that we are forced to use third party library in order to make our widgets more testable, and isolate logic that we don't want to test. But we also know that Navigator works behind the hood as Flutter developers designed it, thus our concern is just making sure we pass correct parameters to that object during the test. If Flutter would have some library supporting spying like jest does, this would help us achieving our goals much easier. However, get_it
package is widely used, thus probably for developers it is not going to cause any problems. https://pub.dev/packages/get_it
- In the test file itself, add the directive for mockito, so the mock for the adapter would be generated. In other words, this approach utilizes mockito library, too: https://pub.dev/packages/mockito
- In the
setUp
, provide that mock as a singleton for the get_it
.
- Write the test and verify the results.
Now, to illustrate my approach, I provide the following code as an example to achieve this type of mock.
// navigator_adapter.dart
// This code just uses Navigator itself.
class NavigatorAdapter {
@optionalTypeArgs
Future<T?> pushReplacementNamed<T extends Object?, TO extends Object?>(
BuildContext context,
String routeName, {
TO? result,
Object? arguments,
}) =>
Navigator.of(context).pushReplacementNamed(
routeName,
result: result,
arguments: arguments,
);
}
// testable.dart
// Our widget we would like to test.
class Testable extends StatelessWidget {
final String routePath;
const Testable({
required this.routePath,
super.key,
});
@override
Widget build(BuildContext context) {
final inPath = _belongsToPath(context);
return InkWell(
// Here, we use get_it package to resolve the object needed. In this way, it becomes possible to provide any stub we like when testing.
onTap: () => GetIt.I<NavigatorAdapter>().pushReplacementNamed(context, routePath),
child: Text('TAP ME!'),
),
);
}
}
// testable_test.dart
Widget buildTestable() {
return MaterialApp(
home: const Scaffold(
body: Testable(
routePath: '/some-path',
),
),
);
}
@GenerateMocks([NavigatorAdapter])
void main() {
final navigatorAdapter = MockNavigatorAdapter();
setUp(() {
// We register the stub generated by mockito.
GetIt.I.registerSingleton<NavigatorAdapter>(navigatorAdapter);
// We make sure to provide some behavior, so exception would not be thrown to us.
when(navigatorAdapter.pushReplacementNamed(any, any))
.thenAnswer((_) => Future.value(null));
});
testWidgets('tap navigates to the specified path',
(WidgetTester tester) async {
// Arrange
await tester.pumpWidget(buildTestable());
// Act
await tester.tap(find.byType(Testable));
// Assert
verify(navigatorAdapter.pushReplacementNamed(any, '/some-path'));
});
}
Note. Do not forget to run the following command before running the test: flutter pub run build_runner build
. And in case something happens with conflicting outputs, the following can be run: flutter pub run build_runner build --delete-conflicting-outputs
. In order to use build_runner
, the following package is needed: https://pub.dev/packages/build_runner