Milad-Akarie / auto_route_library

Flutter route generator
MIT License
1.52k stars 383 forks source link

How to create Widget tests. #1138

Closed Maqcel closed 1 year ago

Maqcel commented 2 years ago

Hi, I'm struggling to find a clever way to write Widget Test with auto_route included. I've searched the entire issues for some help and only managed to work with #745. The solution that was suggested there worked for my login screen but going deeper to the home screen or any other one I tried to test unfortunately failed.

The following assertion was thrown building AutoTabsScaffold:
RouteData operation requested with a context that does not include an RouteData.
The context used to retrieve the RouteData must be that of a widget that is a descendant of a
AutoRoutePage.

Test:

@GenerateMocks([StackRouter])
void main() {
  group('Home Screen Test - ', () {
    late final StackRouter mockStackRouter;

    setUpAll(() {
      mockStackRouter = MockStackRouter();
    });

    testWidgets('Home Screen has BottomNavigation', (tester) async {
      await tester.pumpWidget(giveWidgetMaterialAncestor(
        router: mockStackRouter,
        widgetToTest: const HomeScreen(),
      ));

      expect(find.byType(BottomNavigationBar), findsOneWidget);
    });
  });
}

HomeScreen:

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  static const List<PageRouteInfo> _homeRoutes = [
    ProfileRouter(),
    DevicesRouter(),
    RecentTransfersRouter(),
  ];

  @override
  Widget build(BuildContext context) => AutoTabsScaffold(
        navigatorObservers: () => [AutoRouteObserver()],
        routes: _homeRoutes,
        bottomNavigationBuilder: (context, tabsRouter) =>
            _bottomNavigationBarBuilder(context, tabsRouter),
      );

  BottomNavigationBar _bottomNavigationBarBuilder(
    BuildContext context,
    TabsRouter tabsRouter,
  ) =>
      BottomNavigationBar(
        currentIndex: tabsRouter.activeIndex,
        onTap: tabsRouter.setActiveIndex,
        items: [
          _navigationBarItem(
            context,
            HomeScreenPageType.profile,
            tabsRouter.current.name == ProfileRouter.name,
          ),
          _navigationBarItem(
            context,
            HomeScreenPageType.devices,
            tabsRouter.current.name == DevicesRouter.name,
          ),
          _navigationBarItem(
            context,
            HomeScreenPageType.transfers,
            tabsRouter.current.name == RecentTransfersRouter.name,
          ),
        ],
      );

  BottomNavigationBarItem _navigationBarItem(
    BuildContext context,
    HomeScreenPageType itemType,
    bool isActive,
  ) =>
      BottomNavigationBarItem(
        icon: HomeBottomNavigationBarItemProvider.getItemIconFromType(
          itemType: itemType,
          isActive: isActive,
        ),
        label: HomeBottomNavigationBarItemProvider.getLabelFromType(
          itemType: itemType,
          context: context,
        ),
      );
}

Helper method for providing MaterialApp:

Widget giveWidgetMaterialAncestor({
  required StackRouter router,
  required Widget widgetToTest,
}) =>
    MaterialApp(
      supportedLocales: LocalizationConfig.supportedLocales,
      localizationsDelegates: LocalizationConfig.localizationDelegates,
      theme: LightTheme().themeData,
      home: StackRouterScope(
        controller: router,
        stateHash: 0,
        child: widgetToTest,
      ),
    );

As mentioned this approach was successful for the Login Screen all tests for it:

@GenerateMocks([
  AuthRepository,
  StackRouter,
])
void main() {
  group('Login Screen Tests - ', () {
    late final AuthRepository mockAuthRepository;
    late final StackRouter mockStackRouter;

    setUpAll(() {
      mockAuthRepository = MockAuthRepository();
      mockStackRouter = MockStackRouter();
    });

    testWidgets('Login Failed', (tester) async {
      when(mockAuthRepository.signIn()).thenAnswer(
        (invocation) async => Result.failure(const UnexpectedFailure()),
      );

      when(mockAuthRepository.hasValidUserSession).thenAnswer(
        (invocation) => false,
      );

      await tester.pumpWidget(giveWidgetMaterialAncestorWithBloc(
        router: mockStackRouter,
        widgetToTest: const LoginScreen(),
        cubit: LoginCubit(authRepository: mockAuthRepository),
      ));

      await tester.tap(find.byKey(const ValueKey('loginButton')));

      await tester.pumpAndSettle();

      expect(find.byType(Dialog), findsOneWidget);
    });

    testWidgets('Login Aborted', (tester) async {
      when(mockAuthRepository.signIn()).thenAnswer(
        (invocation) async => Result.failure(const SignInAbortedFailure()),
      );

      when(mockAuthRepository.hasValidUserSession).thenAnswer(
        (invocation) => false,
      );

      await tester.pumpWidget(giveWidgetMaterialAncestorWithBloc(
        router: mockStackRouter,
        widgetToTest: const LoginScreen(),
        cubit: LoginCubit(authRepository: mockAuthRepository),
      ));

      await tester.tap(find.byKey(const ValueKey('loginButton')));

      await tester.pumpAndSettle();

      expect(find.byType(Dialog), findsNothing);
    });

    testWidgets('Login Successful', (tester) async {
      when(mockAuthRepository.signIn()).thenAnswer(
        (invocation) async => Result.success(FakeSessionInfo()),
      );

      when(mockAuthRepository.hasValidUserSession).thenAnswer(
        (invocation) => false,
      );

      when(mockStackRouter.replace(const HomeRoute())).thenAnswer(
        (invocation) async => () {},
      );

      await tester.pumpWidget(giveWidgetMaterialAncestorWithBloc(
        router: mockStackRouter,
        widgetToTest: const LoginScreen(),
        cubit: LoginCubit(authRepository: mockAuthRepository),
      ));

      await tester.tap(find.byKey(const ValueKey('loginButton')));

      await tester.pumpAndSettle();

      verify(mockStackRouter.replace(const HomeRoute()));
    });
  });
}

Mocks created by mockito, I'm strongly looking for some docs/tutorials on how to create widget tests with this package

Milou6 commented 2 years ago

I'm on the same boat right now, without auto_routes router tests find my widgets, but when I try to use it none of the tests find any widget. If someone has resources on how to set this up greatly appreciated.

TheSmartMonkey commented 2 years ago

+1

TheSmartMonkey commented 1 year ago

I've found a way to mock auto_route using get_it

Call the router like this in your widget

getIt<AppRouter>().push(const ScreenHome()));
// intead of
context.router.push(const ScreenHome()));

main.dart

GetIt getIt = GetIt.instance;

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  getIt.registerSingleton<AppRouter>(
    AppRouter(
      checkIfAuthenticated: CheckIfAuthenticated(),
    ),
  );
  runApp(initApp()); // child: MyApp()
}

class MyApp extends StatelessWidget {
  MyApp({Key? key}) : super(key: key);
  final router = getIt<AppRouter>();
  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerDelegate: AutoRouterDelegate(router),
      routeInformationParser: router.defaultRouteParser(),
    );
  }
}

widget_test.dart

import 'package:projectname/routes/router.gr.dart' as router;
import 'package:projectname/screens/log_in.dart';

class AppRouterMock extends Mock implements router.AppRouter {}

void main() {
  TestWidgetsFlutterBinding.ensureInitialized();
  WidgetsFlutterBinding.ensureInitialized();

  setUpAll(() {
    final appRouterMock = AppRouterMock();
    getIt.registerSingleton<router.AppRouter>(appRouterMock);
  });

  testWidgets('Should goto home',
        (WidgetTester tester) async {
    // Given
    when(() => getIt<router.AppRouter>().push(
          const router.ScreenHome(),
        )).thenAnswer((_) async => {});

    // When
    await tester.pumpWidget(
      const MaterialApp(
        home: ScreenLogIn(),
      ),
    );
    await tester.tap(find.byType(ElevatedButton));
    await tester.pumpAndSettle();
    await tester.pump(Duration(seconds: 2));

    // Then
    verify(() => getIt<router.AppRouter>().push(const router.ScreenHome()));
  });
}

Here is a exemple how to use get_it with widget tests : https://github.com/TheSmartMonkey/flutter-http-widget-test

victormarques-ia commented 1 year ago

+1

PankovSerge commented 1 year ago

@Maqcel seems i faced the same problem with AutoTabsScaffold, and successfully proceed to the next step with

return MultiProvider(
    providers: [
      ...AppDependencies.getProviders(),
],
child: Portal(
      child: MaterialApp.router(
        theme: AppTheme.buildTheme(const DesignSystem.light()),
        darkTheme: AppTheme.buildTheme(const DesignSystem.dark()),
        routeInformationParser: router.defaultRouteParser(),
        routerDelegate: router.delegate(),
        themeMode: ThemeMode.light,
        supportedLocales: I18n.supportedLocales,
        localizationsDelegates: const [
          GlobalMaterialLocalizations.delegate,
        ],
      ),
    ),
);

but stuck with error, that provider can't find one of my dependencies in child page (one of route from routes list in AutoTabsScaffold)

BlocProvider.of() called with a context that does not contain a Bloc of type
SampleBlocType

What i want to achieve - it's not a track of transition between the pages, my target - golden test of page which contain multiple tabs.

github-actions[bot] commented 1 year ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions

Petri-Oosthuizen commented 1 year ago

The issue with injecting auto_route into DI (get_it) is that you can't pop from a nested route.

Navigating to a nested page (a page within BottomNavigationBar) with

getIt<AppRouter>().push(const ScreenHome()));

works fine until you want to return from ScreenHome using

getIt<AppRouter>().pop<bool>(false);

The docs mention the following:

navigating without context is not recommended in nested navigation unless you use navigate instead of push and you provide a full hierarchy. e.g router.navigate(SecondRoute(children: [SubChild2Route()]))

Using navigate instead of push means that you can't return a value. The only solution I can see now is to use callbacks on the child page.

github-actions[bot] commented 1 year ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions

guplem commented 1 year ago

+1, I am struggling to find clear information on the subject. IMO, it might be something easily solved by sharing knowledge. Did @Milad-Akarie have a look at this?

Milad-Akarie commented 1 year ago

@guplem what's the main issue here? is it waiting for push and friends pop completers?

guplem commented 10 months ago

@guplem what's the main issue here? is it waiting for push and friends pop completers?

To be honest, @Milad-Akarie , I think that the issue is a lack of an example of how to easily and properly run widget tests with AutoRoute handling the navigation.

There is little to no information about the topic. I wanted to have an app with all the fronted testing, but I had to limit myself to the logic & backend due to not finding a way of testing it with AouteRoute.

Maybe a simple app/demo showing how to navigate between screens while testing the app apart from some basic testing (like testing the counter app) would be enough.

bruntzcreative commented 10 months ago

Also having the same issue with testing. RouteData operation requested with a context that does not include an RouteData. Similar setup to above.

phamquoctrongnta commented 6 months ago

+1. Any solution for this issue? My widget:

@RoutePage()
class MainHomePage extends StatelessWidget {
  const MainHomePage({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return  AutoTabsScaffold(
      routes: [
        MovieDetailRoute(text: 'Center text'),
        const MovieRoute(),
      ],
      bottomNavigationBuilder: (_, tabsRouter) {
        return BottomNavigationBar(
          key: const Key('BottomNavigationBarKey'),
          currentIndex: tabsRouter.activeIndex,
          onTap: tabsRouter.setActiveIndex,
          items: const [
            BottomNavigationBarItem(label: '', icon: Icon(Icons.home)),
            BottomNavigationBarItem(label: '', icon: Icon(Icons.details)),
          ],
        );
      },
    );
  }
}

My test class:

void main(){
  testWidgets('Find a BottomNavigationBar widget', (widgetTester) async {
    await widgetTester.pumpWidget(
      const MaterialApp(
        title: 'Widget test',
        home: MainHomePage(),
      ),
    );
    await widgetTester.pumpAndSettle();
    final bottomNav = find.byType(BottomNavigationBar);
    expect(bottomNav, findsOneWidget);
  });
}

Get an error:

══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════
The following assertion was thrown building AutoTabsScaffold:
RouteData operation requested with a context that does not include an RouteData.
The context used to retrieve the RouteData must be that of a widget that is a descendant of a
AutoRoutePage.
══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════
The following TestFailure was thrown running a test:
Expected: exactly one matching candidate
  Actual: _TypeWidgetFinder:<Found 0 widgets with type "BottomNavigationBar": []>
   Which: means none were found but one was expected
francipvb commented 5 months ago

I have another issue. I am trying to check a router call through the build context when some event occurs in the bottom of the tree, inside a stream.