如何在 Flutter 中通过 Navigator 测试导航

How to test navigation via Navigator in Flutter

比方说,我使用 WidgetTester 在 Flutter 中测试了一个屏幕。有一个按钮,通过 Navigator 执行导航。我想测试那个按钮的行为。

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)
            )
        )
    );
  }

}

测试

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?    
  });
}

我有一个正确的方法来检查那个 Navigator 被调用了吗?或者有没有办法模拟和替换导航器?

请注意,上面的代码实际上会失败并出现异常,因为应用程序中没有声明命名路由 '/nextscreen'。这很容易解决,你不需要指出它。

我主要关心的是如何在 Flutter 中正确处理这个测试场景。

navigator tests in the flutter repo they use the NavigatorObserverclass观察导航:

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);
  }
}

这看起来应该可以满足您的需求,但它可能只能在顶层 (MaterialApp) 中使用,我不确定您是否可以将它提供给一个小部件。

假设以下解决方案是一种通用方法,并不特定于 Flutter。

导航可以从屏幕或小部件中抽象出来。测试可以模拟并注入这个抽象。这种方法应该足以测试这种行为。

有几种方法可以实现这一点。出于此响应的目的,我将展示其中一个。也许可以稍微简化它或使其更加 "Darty".

导航抽象

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');
  }
}

注入小部件

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)
            )
        )
    );
  }

}

测试示例(使用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());
  });
}

虽然 Danny 所说的是正确且有效的,但您也可以创建一个模拟的 NavigatorObserver 以避免任何额外的样板文件:

import 'package:mockito/mockito.dart';

class MockNavigatorObserver extends Mock implements NavigatorObserver {}

这将转化为您的测试用例如下:

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);
  });
}

我在我的博客上写了一篇关于这个的深入文章,which you can find here

受其他帖子的启发,这是我 2022 null-safe Mockito-based 的方法。假设我有这个辅助方法,我想对其进行单元测试:

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

可以这样测试:

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));
    });
  });
}

请记住,您需要安装 Mockito,如下所述:https://pub.dev/packages/mockito

这是另一个答案的修改版本,展示了如何使用 mocktail 而不是 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);
  });
}