DEV Community

Gustavo Guedes
Gustavo Guedes

Posted on • Edited on

Efficient UI Validation: Exploring Widget Testing in Flutter (UI Tests)

A Bit of Context

Widget testing is a type of testing that is sometimes underestimated, but it holds significant value. In this article, we'll explore widget testing, provide tips to help you incorporate it into your daily workflow, and demonstrate its practical applications.

Before diving in, it's essential to note that the official Flutter documentation is an excellent starting point. It offers simple and practical examples that give you a solid understanding of how things work. Here, you'll find a summary of the key information along with insights from someone who has spent considerable time working with this type of testing.

What Problem Does It Solve?

To understand the role and types of widget tests, check out this video on the Flutter YouTube channel. It explains three types of UI tests:

  1. Golden tests(Pixel perfect);
  2. Finder tests(Behavior);
  3. PaitPattern tests(Drawing instructions);

We will focus on the second type, Finder tests, which validate the behavior of your components. As the Flutter documentation states: "Many widgets not only display information but also respond to user interaction. This includes buttons that can be tapped, and TextFields for entering text."

Basic Concepts

Creating this type of test is very similar to unit testing. However, you'll need to add the Flutter widget testing SDK:

dev_dependencies:
  flutter_test:
    sdk: flutter
Enter fullscreen mode Exit fullscreen mode

Once that’s done, you’ll have access to the methods necessary to build and run your tests.

A quick way to create a test file in VSCode is by right-clicking and selecting "Go to Test." This command checks if a test file already exists; if not, it suggests creating one. It’s a simple but useful command, especially since it creates all the necessary folder layers for that file. Pretty neat—give it a try!

go_to_tests_vs_command

Inside your test file, you should see something like this:

testWidgets('Test Description', (WidgetTester tester) async {})
Enter fullscreen mode Exit fullscreen mode

testWidgets is the method used to execute widget tests, and tester is the tool that will be used to find, interact, and much more.

Now, you’ll need to place your widget for testing. The most common approach is as follows:

await tester.pumpWidget(const MyWidget());
Enter fullscreen mode Exit fullscreen mode

This is also a good place to set up some basic configurations for your component, like theme settings or even dependency injection. A best practice is to wrap your component in a MaterialApp to ensure it has all the necessary theme and MediaQuery configurations.

await tester.pumpWidget(MaterialApp(home: const MyWidget()));
Enter fullscreen mode Exit fullscreen mode

To locate elements on the screen, you can use the CommonFinders singleton. It offers various ways to find the element you’re looking for, and the notation is quite straightforward:

final buttonFinder = find.byType(ElevatedButton)
final textFinder = find.text('Hello world!')
final myWidgetByKeyFinder = find.byKey(Key('MyWidget-Key'))
Enter fullscreen mode Exit fullscreen mode

It’s important to note that the variables created, such as buttonFinder and textFinder, don’t store the actual elements but rather a "way" to find them.

expect(buttonFinder, findsOneWidget);
Enter fullscreen mode Exit fullscreen mode

In the test above, I'm looking for exactly one button; if none or more than one is found, the test will fail.

For interacting with elements, you’ll use the tester provided by the testWidgets method. You can perform actions such as tapping, entering text, or dragging. You can also select the element itself.

await tester.enterText(find.byType(TextField), 'hi');

await tester.pump()
Enter fullscreen mode Exit fullscreen mode

To ensure that the widget tree is rebuilt after simulating a user interaction, call pump() or pumpAndSettle(). Here's a brief summary of how they work.

Let’s Practice!

Here’s our example component:

class PrimaryButton extends StatelessWidget {
  PrimaryButton({
    required String title,
    super.key,
    this.onTap,
    this.backgroundColor = Colors.blue,
    this.isLoading = false,
  }) : title = Text(
          title,
        );

  final Widget title;
  final VoidCallback? onTap;
  final Color backgroundColor;
  final bool isLoading;

  @override
  Widget build(BuildContext context) => ElevatedButton(
        style: ButtonStyle(
          backgroundColor: MaterialStateProperty.resolveWith<Color>(
            (Set<MaterialState> states) {
              if (isLoading) {
                return Colors.grey;
              }

              return backgroundColor;
            },
          ),
        ),
        onPressed: isLoading ? null : onTap,
        child: isLoading ? const CircularProgressIndicator() : title,
      );
}
Enter fullscreen mode Exit fullscreen mode

The behaviors that immediately stand out as needing validation are:

  • Change of backgroundColor;
  • When isLoading is true:
    • The onTap should not be callable;
    • A CircularProgressIndicator should be displayed instead of our title;
    • backgroundColor should be Colors.grey;

Validating the backgroundColor Change

Let’s get our component ready for testing:

testWidgets('Slould be able to render and interact with PrimaryButton',
    (tester) async {
  await tester.pumpWidget(MaterialApp(
    home: PrimaryButton(
      title: 'title',
      backgroundColor: Colors.red,
      onTap: () {},
    ),
  ));

  // ...
});
Enter fullscreen mode Exit fullscreen mode

Now, let’s validate if the custom background color is correctly applied.


IMPORTANT

The button rendered on the screen isn’t the PrimaryButton but an ElevatedButton, so the validations and interactions should be performed on this component, not its parent. For example, the onTap method isn’t in the PrimaryButton; if you try to tap it, nothing will happen. But it will work with the ElevatedButton.

The same concept applies to keys; if you want to identify an element using a key, you need to know which widget to assign the key to. In our example, the key of the PrimaryButton is not the same as its child ElevatedButton. So, if you try to trigger onTap through the parent, it won’t work. To use these default Flutter component keys, you need to extend them:

class PrimaryButton extends ElevatedButton {}
Enter fullscreen mode Exit fullscreen mode

This way, the issues mentioned above won’t occur. However, I chose to use the component as is because it’s more common than extending widgets.


To validate if the color we passed is indeed applied, we do:

final buttonFinder = find.byType(ElevatedButton);
final buttonWidget = tester.widget<ElevatedButton>(buttonFinder);

expect(
  buttonWidget.style?.backgroundColor?.resolve({}),
  Colors.red,
);
expect(find.text('title'), findsOneWidget);
Enter fullscreen mode Exit fullscreen mode

With the advent of Material3 and the implementation of MaterialStateProperty, we use the resolve method to obtain the component’s property for a specific state. Since we didn’t pass any state, I used an empty Set.

first_test

Our first validation is now complete. Next, let’s interact with the onTap method.

Interacting with the onTap Method

One simple way to validate whether the method was called is by using a library like mockito or mocktail.

class OnTapMock extends Mock {
  void call();
}

void main() {
  late OnTapMock onTapMock;

  setUp(() {
    onTapMock = OnTapMock();
  });
}
Enter fullscreen mode Exit fullscreen mode

We pass this mock as a callback for the onTap method and can validate:

verifyNever(() => onTapMock());
Enter fullscreen mode Exit fullscreen mode

Now for the actual interaction:

await tester.tap(buttonFinder);

verify(() => onTapMock());
Enter fullscreen mode Exit fullscreen mode

Simple, right?

Handling isLoading == true

Let’s start by creating a new test:

testWidgets('Slould be able to validate PrimaryButton behaivor with isLoading equals true',
    (tester) async {
  await tester.pumpWidget(MaterialApp(
    home: PrimaryButton(
      title: 'title',
      backgroundColor: Colors.red,
      onTap: () => onTapMock(),
      isLoading: true,
    ),
  ));
});
Enter fullscreen mode Exit fullscreen mode

And the validations would look like this:

final buttonFinder = find.byType(ElevatedButton);
final buttonWidget = tester.widget<ElevatedButton>(buttonFinder);

expect(
  buttonWidget.style?.backgroundColor?.resolve({}),
  Colors.grey,
);
expect(find.text('title'), findsNothing);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
verifyNever(() => onTapMock());

await tester.tap(buttonFinder);

verifyNever(() => onTapMock());
Enter fullscreen mode Exit fullscreen mode

This way, we ensure that with the isLoading == true prop:

  1. The background color is correctly set;
  2. The title is not rendered;
  3. The CircularProgressIndicator is displayed;
  4. Tapping the button in this state does not trigger the callback;

Now we can confidently modify this component, knowing that its behavior remains consistent.

Conclusion

Widget testing in Flutter is a powerful and enjoyable process, allowing you to efficiently validate your components' behavior. Sometimes, these tests require you to rethink how your components are structured, but this is similar to what happens with unit tests, where improving the way we build our methods and manage dependencies results in more robust and testable code.

Implementing these tests brings greater confidence when making changes to the code, ensuring that the desired behavior remains intact. So, if you haven't yet integrated widget testing into your workflow, now is the perfect time to start.

That's all for today! See you next time!

Top comments (0)