42

Let's say, I have a test for a screen in Flutter using WidgetTester. There is a button, which executes a navigation via Navigator. I would like to test behavior of that button.

Widget/Screen

class MyScreen extends StatefulWidget {

  MyScreen({Key key}) : super(key: key);

  @override
  _MyScreenState createState() => _MyScreenScreenState();
}

class _MyScreenState extends State<MyScreen> {

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        body: Center(
            child: RaisedButton(
                onPressed: () {
                    Navigator.of(context).pushNamed("/nextscreen");
                },
                child: Text(Strings.traktTvUrl)
            )
        )
    );
  }

}

Test

void main() {

  testWidgets('Button is present and triggers navigation after tapped',
      (WidgetTester tester) async {
    await tester.pumpWidget(MaterialApp(home: MyScreen()));
    expect(find.byType(RaisedButton), findsOneWidget);
    await tester.tap(find.byType(RaisedButton));
    //how to test navigator?    
  });
}

I there a proper way how to check, that Navigator was called? Or is there a way to mock and replace navigator?

Pleas note, that code above will actually fail with an exception, because there is no named route '/nextscreen' declared in application. That's simple to solve and you don't need to point it out.

My main concern is how to correctly approach this test scenario in Flutter.

Josef Adamcik
  • 5,620
  • 3
  • 36
  • 42

6 Answers6

92

While what Danny said is correct and works, you can also create a mocked NavigatorObserver to avoid any extra boilerplate:

import 'package:mockito/mockito.dart';

class MockNavigatorObserver extends Mock implements NavigatorObserver {}

That would translate to your test case as follows:

void main() {
  testWidgets('Button is present and triggers navigation after tapped',
      (WidgetTester tester) async {
    final mockObserver = MockNavigatorObserver();
    await tester.pumpWidget(
      MaterialApp(
        home: MyScreen(),
        navigatorObservers: [mockObserver],
      ),
    );

    expect(find.byType(RaisedButton), findsOneWidget);
    await tester.tap(find.byType(RaisedButton));
    await tester.pumpAndSettle();

    /// Verify that a push event happened
    verify(mockObserver.didPush(any, any));

    /// You'd also want to be sure that your page is now
    /// present in the screen.
    expect(find.byType(DetailsPage), findsOneWidget);
  });
}

I wrote an in-depth article about this on my blog, which you can find here.

Iiro Krankka
  • 4,959
  • 5
  • 38
  • 42
  • 1
    What would be the point of having the NavigatorObserver in this case then? If you want to see you've landed on the correct page, you can just check if finder finds one widget of type 'DetailsPage'. – mirage Nov 24 '20 at 13:32
  • 2
    There are other ways for `DetailsPage` to become visible than just a push event. `MyScreen` could accidentally have a `.pushReplacement()` call instead. That would replace `MyScreen` with `DetailsPage` and there wouldn't be a way to go back to `MyScreen` anymore. That might not be the intended behavior. – Iiro Krankka Nov 25 '20 at 08:31
  • To use this advice, import the [mockito](https://pub.dev/packages/mockito) package in the `dev_dependencies` section of you pubspec.yaml – Ber Jan 04 '21 at 12:21
  • But the OP asked for a pushNamed(). What to do in this case? – TSR Jun 26 '21 at 15:57
  • 16
    `The argument type 'Null' can't be assigned to the parameter type 'Route'` – mLstudent33 Sep 21 '21 at 00:44
  • 1
    If you can, use Mockito's GenerateMocks together with build_runner to generate a mock NavigatorObserver. For eg. ```@GenerateMocks([], customMocks: [ MockSpec(returnNullOnMissingStub: true), ], )```. This ```MockNavigatorObserver``` allows you to pass argument matchers that return null. – Petri Jan 13 '22 at 09:24
  • This work with push but not with PushNamed – jiahao Jun 23 '22 at 07:47
  • @liro- `verify(mockObserver.didPush(any, any));` it gives the error _The argument type 'Null' can't be assigned to the parameter type 'Route'._ – Ravindra S. Patil Jun 21 '23 at 07:30
11

In the navigator tests in the flutter repo they use the NavigatorObserver class to observe navigations:

class TestObserver extends NavigatorObserver {
  OnObservation onPushed;
  OnObservation onPopped;
  OnObservation onRemoved;
  OnObservation onReplaced;

  @override
  void didPush(Route<dynamic> route, Route<dynamic> previousRoute) {
    if (onPushed != null) {
      onPushed(route, previousRoute);
    }
  }

  @override
  void didPop(Route<dynamic> route, Route<dynamic> previousRoute) {
    if (onPopped != null) {
      onPopped(route, previousRoute);
    }
  }

  @override
  void didRemove(Route<dynamic> route, Route<dynamic> previousRoute) {
    if (onRemoved != null)
      onRemoved(route, previousRoute);
  }

  @override
  void didReplace({ Route<dynamic> oldRoute, Route<dynamic> newRoute }) {
    if (onReplaced != null)
      onReplaced(newRoute, oldRoute);
  }
}

This looks like it should do what you want, however it may only work form the top level (MaterialApp), I'm not sure if you can provide it to just a widget.

Danny Tuppeny
  • 40,147
  • 24
  • 151
  • 275
  • 2
    That's definitely a way how to do it, thank you for pointing it out. However, I feel that it would be a bit tedious to use and hard to read in tests when used regularly. Maybe I could try to find some clever way to hide it behind some helper or syntactic sugar. Or I might just encapsulate Navigator in custom abstraction and mock that. – Josef Adamcik Jun 06 '18 at 09:04
  • @JosefAdamcik a "less tedious way" would be mocking the `NavigatorObserver` with mockito, as seen in this answer: https://stackoverflow.com/a/51983194/940036 – Iiro Krankka Jan 21 '21 at 06:24
  • This works great. I found an example here: https://harsha973.medium.com/widget-testing-pushing-a-new-page-13cd6a0bb055 – jiahao Jun 23 '22 at 07:48
  • It seems like they changed it to nullable. Here is the updated version https://github.com/flutter/flutter/blob/e7b7ebc066c1b2a5aa5c19f8961307427e0142a6/packages/flutter/test/widgets/observer_tester.dart – Yonggan Dec 02 '22 at 08:03
3

Inspired by the other posts, this is my 2022 null-safe Mockito-based approach. Imagine I have this helper method I want to unit test:

navigateToNumber(int number, BuildContext context) {
  Navigator.of(context).pushNamed(
      number.isEven ? '/even' : '/odd'
  );
}

It can be tested this way:

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:mockito/annotations.dart';
import 'package:my_app/number_route_helper.dart';

import 'number_route_helper_test.mocks.dart';


@GenerateMocks([], 
  customMocks: [
    MockSpec<NavigatorObserver>(returnNullOnMissingStub: true)
  ])
void main() {
  group('NumberRouteHelper', () {
    testWidgets('navigateToNumber', (WidgetTester tester) async {
      final mockObserver = MockNavigatorObserver();

      // "Fake" routes used to verify the right route was pushed
      final evenRoute = MaterialPageRoute(builder: (_) => Container());
      final oddRoute = MaterialPageRoute(builder: (_) => Container());
      await tester.pumpWidget(
        MaterialApp(
          home: Container(),
          navigatorObservers: [mockObserver],
          onGenerateRoute: (RouteSettings settings) {
            switch (settings.name) {
            case '/even':
              return evenRoute;
            case '/odd':
              return oddRoute;
            }
          }
        ),
      );

      final BuildContext context = tester.element(find.byType(Container));

      /// Verify that a push to evenRoute happened 
      navigateToNumber(2, context);
      await tester.pumpAndSettle();
      verify(mockObserver.didPush(evenRoute, any));

      /// Verify that a push to oddRoute happened
      navigateToNumber(3, context);
      await tester.pumpAndSettle();
      verify(mockObserver.didPush(oddRoute, any));
    });
  });
}

Just remember you need to have Mockito installed, as described here: https://pub.dev/packages/mockito

Johannes Fahrenkrug
  • 42,912
  • 19
  • 126
  • 165
3

This is modified version of the other answer to show how to do it with mocktail instead of mockito:

import 'package:mocktail/mocktail.dart';

class MockNavigatorObserver extends Mock implements NavigatorObserver {}

class FakeRoute extends Fake implements Route {}
void main() {
  setUpAll(() {
    registerFallbackValue(FakeRoute());
  });

  testWidgets('Button is present and triggers navigation after tapped',
      (WidgetTester tester) async {
    final mockObserver = MockNavigatorObserver();
    await tester.pumpWidget(
      MaterialApp(
        home: MyScreen(),
        navigatorObservers: [mockObserver],
      ),
    );

    expect(find.byType(RaisedButton), findsOneWidget);
    await tester.tap(find.byType(RaisedButton));
    await tester.pumpAndSettle();

    verify(() => mockObserver.didPush(any(), any()));

    expect(find.byType(DetailsPage), findsOneWidget);
  });
}
CuriousCat
  • 33
  • 5
Taylan
  • 3,045
  • 3
  • 28
  • 38
  • @Taylan- `verify(mockObserver.didPush(any(), any()));` it gives the error _An expression whose value is always 'null' can't be dereferenced._ – Ravindra S. Patil Jun 21 '23 at 07:32
1

Following solution is, let's say, a general approach and it's not specific to Flutter.

Navigation could be abstracted away from a screen or a widget. Test can mock and inject this abstraction. This approach should be sufficient for testing such behavior.

There are several ways how to achieve that. I will show one of those, for purpose of this response. Perhaps it's possible to simplify it a bit or to make it more "Darty".

Abstraction for navigation

class AppNavigatorFactory {
  AppNavigator get(BuildContext context) =>
      AppNavigator._forNavigator(Navigator.of(context));
}

class TestAppNavigatorFactory extends AppNavigatorFactory {
  final AppNavigator mockAppNavigator;

  TestAppNavigatorFactory(this.mockAppNavigator);

  @override
  AppNavigator get(BuildContext context) => mockAppNavigator;
}

class AppNavigator {
  NavigatorState _flutterNavigator;
  AppNavigator._forNavigator(this._flutterNavigator);

  void showNextscreen() {
    _flutterNavigator.pushNamed('/nextscreen');
  }
}

Injection into a widget

class MyScreen extends StatefulWidget {
  final _appNavigatorFactory;
  MyScreen(this._appNavigatorFactory, {Key key}) : super(key: key);

  @override
  _MyScreenState createState() => _MyScreenState(_appNavigatorFactory);
}

class _MyScreenState extends State<MyScreen> {
  final _appNavigatorFactory;

  _MyScreenState(this._appNavigatorFactory);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        body: Center(
            child: RaisedButton(
                onPressed: () {
                    _appNavigatorFactory.get(context).showNextscreen();
                },
                child: Text(Strings.traktTvUrl)
            )
        )
    );
  }

}

Example of a test (Uses Mockito for Dart)

class MockAppNavigator extends Mock implements AppNavigator {}

void main() {
  final appNavigator = MockAppNavigator();

  setUp(() {
    reset(appNavigator);
  });


  testWidgets('Button is present and triggers navigation after tapped',
      (WidgetTester tester) async {

    await tester.pumpWidget(MaterialApp(home: MyScreen(TestAppNavigatorFactory())));

    expect(find.byType(RaisedButton), findsOneWidget);
    await tester.tap(find.byType(RaisedButton));

    verify(appNavigator.showNextscreen());
  });
}
SoftWyer
  • 1,986
  • 28
  • 33
Josef Adamcik
  • 5,620
  • 3
  • 36
  • 42
0

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:

  1. Introduce the NavigatorAdapter. This logic has nothing special, it just redirects the calls to the actual Navigator instance.
  2. 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
  3. 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
  4. In the setUp, provide that mock as a singleton for the get_it.
  5. 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