Flutter Hooks – Testing Your Widgets

Testing your Widget is important during Application Development. When it grows more complex, it becomes more work to manually test everything. Having tests for your widgets makes sure that the Widgets behave as we expect them to. This saves time when developing new features. This way, you do not have to test all other features again. In this blog post, we are going to describe how to create tests for your Widgets that contain Flutter Hooks.

Setup the project

Before we can start with coding, we are going to add a dependency to the project, namely Flutter Hooks. Furthermore, we need to make sure the Flutter Test SDK is added to the development dependencies.

dependencies:
  flutter:
    sdk: flutter
  flutter_hooks: ^0.15.0

dev_dependencies:
  flutter_test:
    sdk: flutter

Do not forget to install the dependency, running the following command:

flutter pub get

Writing our First Test for the UseState Hook

Let’s create a sample widget for our first hook, the useState hook. The useState hook is used to subscribe to a variable. When the variable changes, the Widget is marked for a rebuild. We will show this with a simple counter app. We can use the useState hook to subscribe to an integer value. This will be the count. First, we will display this with a simple Text element. Secondly, we add a button to increase the value of the count. The value can be increased by updating the state value through the counter variable.

class UseStateExample extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final counter = useState(0);
    return Row(
      children: [
        Text(
          counter.value.toString(),
        ),
        RaisedButton(
            child: Icon(Icons.add),
            onPressed: () {
              counter.value++;
            })
      ],
    );
  }
}

Since we have finished our first Widget, let’s create a test to see if it does what we are expecting it to do. To test the Widget, we wrap it in a HookBuilder, so that our test can handle the HookWidget. We also wrap it with an MaterialApp, so that the default Widgets act the same.

With the await tester.pumpWidget, we do now have an active Widget. We can now add expectations about our Widget. Since we have not pressed the button yet, we expect to find the number zero and not the number one. Now, let’s press the button. We can use the tester.tap function to press something. We can find our button by the icon we added to the button. After we pressed the button, this will mark our Widget for a rebuild. However, we need to pump the Widget again, to make sure the Widget is rebuilt. Now we can assert our expectations again. Only this time, we expect the number to be a one, instead of a zero.

 testWidgets('Testing the UseState Example', (WidgetTester tester) async {
    await tester.pumpWidget(HookBuilder(builder: (context) {
      return MaterialApp(home: UseStateExample());
    }));
    expect(find.text('0'), findsOneWidget);
    expect(find.text('1'), findsNothing);

    await tester.tap(find.byIcon(Icons.add));
    // The widget is now marked for rebuild, but the rebuilt has not yet been triggered
    expect(find.text('0'), findsOneWidget);
    expect(find.text('1'), findsNothing);

    // We do now trigger the rebuilt
    await tester.pump();
    expect(find.text('0'), findsNothing);
    expect(find.text('1'), findsOneWidget);
  });

Testing the Value Changed Hook

The second hook we are going to test is the useValueChanged hook. This hook executes a function each time the variable it watches is changed. We created a simple Widget to demonstrate this. We are going to use the useState hook to keep two variables. One we are going to display and one we are going to watch. With the useValueChanged we are going to watch the value of the counter, and when it changes, we increase the other counter with two. To change the value we are watching, we are going to add a button that will increase the value by one.

class UseValueChangedExample extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final counter = useState(0);
    final changedCounter = useState(0);
    useValueChanged(
        counter.value,
        (oldValue, oldResult) =>
            {changedCounter.value = changedCounter.value + 2});
    return Row(
      children: [
        Text(
          changedCounter.value.toString(),
        ),
        RaisedButton(
            child: Icon(Icons.add),
            onPressed: () {
              counter.value++;
            })
      ],
    );
  }
}

With the await tester.pumpWidget, we can create an active Widget. We can now add expectations about our Widget. Since we have not pressed the button yet, we expect to find the number zero and not the number one or two. Now, let’s press the button. We can use the tester.tap function to press something. We can find our button by the icon we added to the button. After we pressed the button, this will mark our Widget for a rebuild. However, we need to pump the Widget again, to make sure the Widget is rebuilt. Now we can assert our expectations again. Only this time, we expect the number to be two, as the useValueChanged changes the original value by two.

testWidgets('Testing the Use Value Changed Example',
          (WidgetTester tester) async {
        await tester.pumpWidget(HookBuilder(builder: (context) {
          return MaterialApp(home: UseValueChangedExample());
        }));
        expect(find.text('0'), findsOneWidget);
        expect(find.text('2'), findsNothing);

        await tester.tap(find.byIcon(Icons.add));
        await tester.pump();

        expect(find.text('0'), findsNothing);
        expect(find.text('1'), findsNothing);
        expect(find.text('2'), findsOneWidget);
      });

Testing the Animation Controller Hook

Another popular hook of the Flutter Hooks project is the useAnimationController. This hook can be used to simplify animations. For simplicity, we are going to create a very basic animation. We are just going to display the current value of the animation controller. More complicated examples with Riverpod can be found here, or just if you are only interested in Flutter Hooks animations. To display the value of the animation controller, we can use an AnimatedBuilder. Here we provide the controller we get from the useAnimationController Hook. In the builder, we can then access the value of the controller.

class UseAnimationExample extends HookWidget {
  final Duration duration = const Duration(milliseconds: 1000);

  @override
  Widget build(BuildContext context) {
    final _controller = useAnimationController(duration: duration);
    _controller.forward();
    return AnimatedBuilder(
        animation: _controller,
        builder: (context, child) {
          return Container(child: Text(_controller.value.toString()));
        });
  }
}

With the await tester.pumpWidget, we can create an active Widget. We can now add expectations about our Widget. The test does not advance in time unless we tell it to. This means, that after the first pumpWidget, the value displayed is still ‘0.0’. We can advance the time, with a tester.pump(Duration(milliseconds: 500)). Since the duration of the animation is one second, we expect the value to be halfway. The animation controller by default goes from zero to one. When we continue till we are completely through the animation, the value remains 1.0.

  testWidgets('Test the useAnimation Hook', (WidgetTester tester) async {
    await tester.pumpWidget(HookBuilder(builder: (context) {
      return MaterialApp(home: UseAnimationExample());
    }));
    expect(find.text('0.0'), findsOneWidget);

    await tester.pump(Duration(milliseconds: 500));

    expect(find.text('0.5'), findsOneWidget);

    await tester.pump(Duration(milliseconds: 500));
    expect(find.text('1.0'), findsOneWidget);

    await tester.pump(Duration(milliseconds: 500));
    expect(find.text('1.0'), findsOneWidget);
  });

Testing the Use Effect Hook

Let’s get started with our final example. For this example we are going to show useEffect hook.

useEffect is called synchronously on every build, unless keys is specified. In which case useEffect is called again only if any value inside keys as changed.

Flutter hooks documentation

We are creating a Widget with an useEffect hook, that will increase the value of the count on the first build, but not on recurring builds.

class UseEffectOnceExample extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final counter = useState(0);
    useEffect(() {
      counter.value++;
    }, const []);
    return Row(
      children: [
        Text(
          counter.value.toString(),
        ),
        RaisedButton(
            child: Icon(Icons.add),
            onPressed: () {
              counter.value++;
            })
      ],
    );
  }
}

The test is almost the same as the other tests. Except we now start with the value one, which is increased by one when pressed on the button.

 testWidgets('Testing the useEffect on first build',
      (WidgetTester tester) async {
    await tester.pumpWidget(HookBuilder(builder: (context) {
      return MaterialApp(home: UseEffectOnceExample());
    }));
    expect(find.text('1'), findsOneWidget);
    expect(find.text('0'), findsNothing);

    // Tap the '+' icon and trigger a frame.
    await tester.tap(find.byIcon(Icons.add));
    await tester.pump();

    expect(find.text('1'), findsNothing);
    expect(find.text('2'), findsOneWidget);
  });

If you are interested in the full code, that can be found here on Github. There are also two other examples of the useEffect hook. One that triggers on each build and another one that triggers when the variable has changed. Thank you for reading and if you have any suggestions, comments, or remarks. Feel free to leave a comment.

Leave a Reply