On your road to 100% coverage, you might end up needing to test a RefreshIndicator. This small article aims at showing you how to do that and the common pitfalls to avoid!

The code to test

class MyHomePage extends StatefulWidget {
  const MyHomePage({
    Key? key,
  }) : super(key: key);

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  String title = 'Hello';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(title),
      ),
      body: RefreshIndicator(
        onRefresh: () async => setState(() {
          title = 'Hey';
        }),
        child: ListView.builder(
          itemBuilder: (_, i) => Text('$i'),
          itemCount: 200,
        ),
      ),
    );
  }
}

As you can see, it's a simple ListView with a onRefresh that change the title from Hello to Hey.

How to test

 testWidgets('Should change title', (WidgetTester tester) async {
    final SemanticsHandle handle = tester.ensureSemantics();

    await tester.pumpWidget(const MaterialApp(home: MyHomePage()));

    expect(find.text('Hello'), findsOneWidget);
    expect(find.text('Hey'), findsNothing);

    await tester.fling(find.text('1'), const Offset(0.0, 300.0), 1000.0);
    await tester.pump();

    expect(
        tester.getSemantics(find.byType(RefreshProgressIndicator)),
        matchesSemantics(
          label: 'Refresh',
        ));

    await tester
        .pump(const Duration(seconds: 1)); // finish the scroll animation
    await tester.pump(
        const Duration(seconds: 1)); // finish the indicator settle animation
    await tester.pump(
        const Duration(seconds: 1)); // finish the indicator hide animation

    expect(find.text('Hey'), findsOneWidget);
    expect(find.text('Hello'), findsNothing);

    handle.dispose();
  });

There are several things in this test:

  • First, you initialize the SemanticHandle; it will make checking that RefreshIndicator is in refresh state easier
  • Then you pump your widget, and you check that you are in the correct starting state
  • You then use fling to quickly swipe on the screen and pump several times to wait for the animation to settle.
  • Finally, you check that your title has been appropriately changed, and you dispose of the semantic handle.

Pitfall to avoid

I once got stuck on a test for a simple reason:

My content wasn't big enough to get scrolled

It's why I use testing to make sure that some edge cases I didn't think about are covered!

Since I wanted my user always to be able to refresh the list, even if there were a single element, I added a

physics: const AlwaysScrollableScrollPhysics(),

to my ListView, and my test was finally passing!

Conclusion

Thanks for reading through, and don't forget to reach me on Twitter if you have any other questions!

If you wanna read any other articles from my 100% coverage series, click below!

100% Coverage - Guillaume Bernos
This series of articles helps you achieve 100% coverage in your Flutter project. Why 100%? Because once you begin to test everything, your codebase cleans itself up. You identify dead code and edge cases quickly, and you end up with fewer bugs!