Efficient UI Validation: Exploring Widget Testing in Flutter (UI Tests)
Gustavo Guedes
Posted on August 14, 2024
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:
- Golden tests(Pixel perfect);
- Finder tests(Behavior);
- 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
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!
Inside your test file, you should see something like this:
testWidgets('Test Description', (WidgetTester tester) async {})
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());
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()));
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'))
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);
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()
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,
);
}
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: () {},
),
));
// ...
});
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 {}
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);
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
.
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();
});
}
We pass this mock as a callback for the onTap
method and can validate:
verifyNever(() => onTapMock());
Now for the actual interaction:
await tester.tap(buttonFinder);
verify(() => onTapMock());
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,
),
));
});
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());
This way, we ensure that with the isLoading == true
prop:
- The background color is correctly set;
- The title is not rendered;
- The
CircularProgressIndicator
is displayed; - 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!
Posted on August 14, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.