Skip to content

[go_router] - Make public method used in the build more test friendly #120418

@ValentinVignal

Description

@ValentinVignal

Use case

canPop is a method that can be used in the build method as it reads the state of the router without changing it.

For example, a custom back button could be implemented as

class GoRouterBackButton extends StatelessWidget {
  const GoRouterBackButton();
  @override
  Widget build(BuildContext context) {
    return IconButton(
      onPressed: GoRouter.of(context).canPop()
        ? () {
          GoRouter.of(context).pop();
        }
        null,
      icon: const Icon(Icons.arrow_back),
    ),
  }
}

This works fine for the production code. The issue is in the test, if I do

test.pumpWidget(const MaterialApp(
  home: Scaffold(
    body: GoRouterBackButton(),
  ),
));

I'll get the error

No inherited error
00:03 +0: Pump widget                                                                                                                                                                                                                
══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════
The following assertion was thrown building Home(dirty):
No GoRouter found in context
'package:go_router/src/router.dart':
Failed assertion: line 300 pos 12: 'inherited != null'

The relevant error-causing widget was:
  Home Home:file:///Users/valentin/Perso/Projects/flutter_app_stable/test/widget_test.dart:13:35

When the exception was thrown, this was the stack:
#2      GoRouter.of (package:go_router/src/router.dart:300:12)
#3      Home.build (package:flutter_app_stable/main.dart:39:35)
#4      StatelessElement.build (package:flutter/src/widgets/framework.dart:4949:49)
#5      ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:4878:15)
#6      Element.rebuild (package:flutter/src/widgets/framework.dart:4604:5)
#7      ComponentElement._firstBuild (package:flutter/src/widgets/framework.dart:4859:5)
#8      ComponentElement.mount (package:flutter/src/widgets/framework.dart:4853:5)
#9      Element.inflateWidget (package:flutter/src/widgets/framework.dart:3863:16)
#10     Element.updateChild (package:flutter/src/widgets/framework.dart:3586:20)
#11     RenderObjectToWidgetElement._rebuild (package:flutter/src/widgets/binding.dart:1195:16)
#12     RenderObjectToWidgetElement.update (package:flutter/src/widgets/binding.dart:1172:5)
#13     RenderObjectToWidgetElement.performRebuild (package:flutter/src/widgets/binding.dart:1186:7)
#14     Element.rebuild (package:flutter/src/widgets/framework.dart:4604:5)
#15     BuildOwner.buildScope (package:flutter/src/widgets/framework.dart:2667:19)
#16     AutomatedTestWidgetsFlutterBinding.drawFrame (package:flutter_test/src/binding.dart:1191:19)
#17     RendererBinding._handlePersistentFrameCallback (package:flutter/src/rendering/binding.dart:378:5)
#18     SchedulerBinding._invokeFrameCallback (package:flutter/src/scheduler/binding.dart:1175:15)
#19     SchedulerBinding.handleDrawFrame (package:flutter/src/scheduler/binding.dart:1104:9)
#20     AutomatedTestWidgetsFlutterBinding.pump.<anonymous closure> (package:flutter_test/src/binding.dart:1057:9)
#23     TestAsyncUtils.guard (package:flutter_test/src/test_async_utils.dart:71:41)
#24     AutomatedTestWidgetsFlutterBinding.pump (package:flutter_test/src/binding.dart:1043:27)
#25     WidgetTester.pumpWidget.<anonymous closure> (package:flutter_test/src/widget_tester.dart:554:22)
#28     TestAsyncUtils.guard (package:flutter_test/src/test_async_utils.dart:71:41)
#29     WidgetTester.pumpWidget (package:flutter_test/src/widget_tester.dart:551:27)
#30     main.<anonymous closure> (file:///Users/valentin/Perso/Projects/flutter_app_stable/test/widget_test.dart:13:18)
#31     testWidgets.<anonymous closure>.<anonymous closure> (package:flutter_test/src/widget_tester.dart:171:29)
<asynchronous suspension>
<asynchronous suspension>
(elided 7 frames from class _AssertionError, dart:async, and package:stack_trace)

════════════════════════════════════════════════════════════════════════════════════════════════════
00:03 +0 -1: Counter increments smoke test [E]                                                                                                                                                                                                         
  Test failed. See exception logs above.
  The test description was: Counter increments smoke test
  

To run this test again: /Users/valentin/flutter/flutter/bin/cache/dart-sdk/bin/dart test /Users/valentin/Perso/Projects/flutter_app_stable/test/widget_test.dart -p vm --plain-name 'Counter increments smoke test'
00:03 +0 -1: Some tests failed.         

because the GoRouter.of(context) doesn't find any inherited above in the widget tree.

To fix it I can wrap the widget with InheritedGoRouter:

test.pumpWidget(MaterialApp(
  home: Scaffold(
    body: InheritedGoRouter(
      goRouter: router,
      child: const GoRouterBackButton(),
    ),
  ),
));

But doing so raises another error

No context error
00:03 +0: Pump widget                                                                                                                                                                                                                                  
══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════
The following _CastError was thrown building Home(dirty, dependencies: [InheritedGoRouter]):
Null check operator used on a null value

The relevant error-causing widget was:
  Home Home:file:///Users/valentin/Perso/Projects/flutter_app_stable/test/widget_test.dart:17:22

When the exception was thrown, this was the stack:
#0      GoRouterDelegate._createNavigatorStateIterator (package:go_router/src/delegate.dart:64:68)
#1      GoRouterDelegate.canPop (package:go_router/src/delegate.dart:102:46)
#2      GoRouter.canPop (package:go_router/src/router.dart:146:36)
#3      Home.build (package:flutter_app_stable/main.dart:39:47)
#4      StatelessElement.build (package:flutter/src/widgets/framework.dart:4949:49)
#5      ComponentElement.performRebuild (package:flutter/src/widgets/framework.dart:4878:15)
#6      Element.rebuild (package:flutter/src/widgets/framework.dart:4604:5)
#7      ComponentElement._firstBuild (package:flutter/src/widgets/framework.dart:4859:5)
#8      ComponentElement.mount (package:flutter/src/widgets/framework.dart:4853:5)
...     Normal element mounting (7 frames)
#15     Element.inflateWidget (package:flutter/src/widgets/framework.dart:3863:16)
#16     Element.updateChild (package:flutter/src/widgets/framework.dart:3586:20)
#17     RenderObjectToWidgetElement._rebuild (package:flutter/src/widgets/binding.dart:1195:16)
#18     RenderObjectToWidgetElement.update (package:flutter/src/widgets/binding.dart:1172:5)
#19     RenderObjectToWidgetElement.performRebuild (package:flutter/src/widgets/binding.dart:1186:7)
#20     Element.rebuild (package:flutter/src/widgets/framework.dart:4604:5)
#21     BuildOwner.buildScope (package:flutter/src/widgets/framework.dart:2667:19)
#22     AutomatedTestWidgetsFlutterBinding.drawFrame (package:flutter_test/src/binding.dart:1191:19)
#23     RendererBinding._handlePersistentFrameCallback (package:flutter/src/rendering/binding.dart:378:5)
#24     SchedulerBinding._invokeFrameCallback (package:flutter/src/scheduler/binding.dart:1175:15)
#25     SchedulerBinding.handleDrawFrame (package:flutter/src/scheduler/binding.dart:1104:9)
#26     AutomatedTestWidgetsFlutterBinding.pump.<anonymous closure> (package:flutter_test/src/binding.dart:1057:9)
#29     TestAsyncUtils.guard (package:flutter_test/src/test_async_utils.dart:71:41)
#30     AutomatedTestWidgetsFlutterBinding.pump (package:flutter_test/src/binding.dart:1043:27)
#31     WidgetTester.pumpWidget.<anonymous closure> (package:flutter_test/src/widget_tester.dart:554:22)
#34     TestAsyncUtils.guard (package:flutter_test/src/test_async_utils.dart:71:41)
#35     WidgetTester.pumpWidget (package:flutter_test/src/widget_tester.dart:551:27)
#36     main.<anonymous closure> (file:///Users/valentin/Perso/Projects/flutter_app_stable/test/widget_test.dart:14:18)
#37     testWidgets.<anonymous closure>.<anonymous closure> (package:flutter_test/src/widget_tester.dart:171:29)
<asynchronous suspension>
<asynchronous suspension>
(elided 5 frames from dart:async and package:stack_trace)

════════════════════════════════════════════════════════════════════════════════════════════════════
00:03 +0 -1: Pump widget [E]                                                                                                                                                                                                                           
  Test failed. See exception logs above.
  The test description was: Pump widget
  

To run this test again: /Users/valentin/flutter/flutter/bin/cache/dart-sdk/bin/dart test /Users/valentin/Perso/Projects/flutter_app_stable/test/widget_test.dart -p vm --plain-name 'Pump widget'
00:04 +0 -1: Some tests failed.                                   

because the GoRouter router was never given to the MaterialApp.router and never global key or context.

It means I have to create a MaterialApp.router(routerConfig: router) in my test, but doing so forces me to mount the entire app/page and not the specific widget I want to write a test on (and this widget might not be somewhere easily accessible from the page built).

A workaround I found was to use

extension on GoRouter {
  bool safeCanPop() {
    try {
      return canPop();
    } catch (_) {
      return false;
    }
  }
}

But I feel something better could be done at the package level to not force the user to do that.

Details

Or you can check out
https://github.com/ValentinVignal/flutter_app_stable/tree/go-router/can-pop-throws-errors

Code example
// main.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

void main() {
  runApp(const MyApp());
}

final router = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const Home(),
    )
  ],
);

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerConfig: router,
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            IconButton(
              onPressed: GoRouter.of(context).canPop() // Or use router.canPop()
                  ? () {
                      GoRouter.of(context).pop();
                    }
                  : null,
              icon: const Icon(Icons.arrow_back),
            ),
            ElevatedButton(
              onPressed: () {
                GoRouter.of(context).push('/');
              },
              child: const Text('Push'),
            ),
          ],
        ),
      ),
    );
  }
}
// test.dart

import 'package:flutter_app_stable/main.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('Pump widget', (WidgetTester tester) async {
    await tester.pumpWidget(const Home());
    
    // Or
   await tester.pumpWidget(
      InheritedGoRouter(
        goRouter: router,
        child: const Home(),
      ),
    );
  });
}

Proposal

We could wrap the body of canPop in a try/catch (like safeCanPop) so it never throws. Maybe there is something better than that to do instead

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Issues that are less important to the Flutter projecta: tests"flutter test", flutter_test, or one of our testsc: new featureNothing broken; request for a new capabilityc: proposalA detailed proposal for a change to Flutterframeworkflutter/packages/flutter repository. See also f: labels.p: go_routerThe go_router packagepackageflutter/packages repository. See also p: labels.r: fixedIssue is closed as already fixed in a newer version

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions