如何在 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);
});
}
比方说,我使用 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);
});
}