Flutter Hooks – Testing Your Widgets

Testing your Widget is important during Application Development. When it grows more complex, it becomes more work to test everything manually. 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 will describe how to create tests for your Widgets that contain Flutter Hooks.

Setup the project

Before we can start with coding, we will 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. Next, 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 expect 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 a 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 one instead of 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 will watch the counter’s value, and when it changes, we increase the other counter with two. To change the value we are watching, we will 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. First, for simplicity, we are going to create a fundamental animation. Then, we are just going to display the current value of the animation controller. You can find more complicated examples with Riverpod 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, which will increase the count’s value 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. However, 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, you can find that here on Github. There are also two other examples of the useEffect hook. First, there is an example that triggers each build and a second 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