Flutter Widget Testing Intro

Madacode
6 min readFeb 11, 2023

--

Testing widgets without the human eye.

Widgets. They’re everywhere in our Flutter app, they’re the bread and butter of our app’s existence, without which our app is nothing but loose jumble of code.

While Widgets are core part of our app, they are sometimes neglected because we are too focused in perfecting our app’s logics and forgoing attention to Widgets. In this article, we are trying to give Widgets some appreciation and love by making Widget tests.

What is a Widget test exactly? Widget tests are principally the same as unit test, but they operate in Widget basis rather than function/method basis as in unit tests. While in unit tests we assert for return value, in Widget tests we assert for Widget state given a Widget parameter or interaction imposed to the Widget.

Why Widget tests are useful? They are useful in the same way Unit Tests are useful. Except, Widget tests cannot validate your widget’s UI design correctness, a pair of human eye is still needed for that, sorry.

Enough intro, now let’s grab a copy of retrofit_dio_example app from https://medium.com/@madacode/flutter-mvvm-unit-testing-eae3c38b2c and get into a case study of a Widget and make a test for it.

Given this Widget:

class PostItemWidget extends StatelessWidget {
const PostItemWidget({
Key? key,
required this.item,
this.onClick,
}) : super(key: key);

final Post item;
final VoidCallback? onClick;

@override
Widget build(BuildContext context) {
final ButtonStyle style = ElevatedButton.styleFrom(
textStyle: const TextStyle(
fontSize: 20,
),
);

return Padding(
padding: EdgeInsets.all(16),
child: Column(
children: <Widget>[
Text(
item.title ?? '',
),
if (item.title?.isNotEmpty == true) ...<Widget>[
SizedBox(
height: 16,
),
ElevatedButton(
style: style,
onPressed: onClick,
child: const Text('See Detail'),
),
],
],
),
);
}
}

There are 2 possible test cases:

  1. PostItemWidget displays title and a button;
  2. PostItemWidget button does not appear when item param title field is null;

Let’s lay the widget test structures out:

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:retrofit_dio_example/core/models/post.dart';
import 'package:retrofit_dio_example/ui/widgets/post_item_widget.dart';

void main() {
testWidgets('PostItemWidget displays title and a button', (
WidgetTester tester,
) async {
});

testWidgets('PostItemWidget button does not appear when item param title field is null', (
WidgetTester tester,
) async {
});
}

Do note that Widget tests uses testWidgets to describe a test case instead of using test, and each test case function has WidgetTester tester defined ready to use for widget testing. WidgetTester tester is used to pump a Widget, that is to “render” a Widget into existence by calling pumpWidget and also to control and trigger frame change by calling pump.

With the Widget test scaffolding in place, let’s fill in the first widget test case, that is to see if the text and button is there in PostItemWidget:

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:retrofit_dio_example/core/models/post.dart';
import 'package:retrofit_dio_example/ui/widgets/post_item_widget.dart';

void main() {

// Test data
final post = Post(
userId: 1,
id: 1,
title: 'Test Title',
body: 'Test Body',
);

testWidgets(
'PostItemWidget displays title and a button',
(WidgetTester tester) async {
// Build the widget
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: PostItemWidget(
item: post,
onClick: () {},
),
),
),
);
// Check that the title is displayed
expect(find.text(post.title!), findsOneWidget);
// Check that the button is displayed
expect(find.text('See Detail'), findsOneWidget);
expect(find.byType(ElevatedButton), findsOneWidget);
},
);

testWidgets(
'PostItemWidget button does not appear when item param title field is null',
(WidgetTester tester) async {
},
);
}

In this Widget test case, we defined an instance of Post class since it is a required parameter to instantiate a PostItemWidget Widget. The contents of the Post instance does not matter since in this test case we are merely testing whether or not the text and button exists.

After instantiating the Post object, we move on to actually pumping the PostItemWidget Widget, to pump a Widget, it must be wrapped in a MaterialApp Widget or otherwise it would return an error, this is because all Material Widgets are under Material Flutter UI components and therefore depends on MaterialApp (source: https://stackoverflow.com/questions/67672177/custom-widget-testing-needs-material-widget-to-test).

With widgets pumped, is is now time to assert that both the text section and button actually “appears” on the “screen”. “Appears” here means that the text and button section is found when searched with Widget finder. You could choose 2 approach to find a Widget:

  • Find it by text value in the Widget; or
  • Find it by key;
  • Find it by Widget type.

In this Widget test, we choose the first and third approach, to find the widget by text value and by the Widget type. On the next article, we will cover on how to find Widget by key, as it is a cleaner approach to Widget testing instead of finding it by magic texts popping around the Widget test file.

To find Widget by text, we simply use find which are included in flutter_test package. calling find.text with the desired text we want to search as the parameter, such example are find.text(post.title!). Whereas to find a Widget by Widget type, we use find.byType with the Widget type we desire as the parameter, with such example are find.byType(ElevatedButton).

With our finders defined, we expect our finders, not with a primitive value, but instead with a custom matcher that is provided by flutter_test package ready for us to use. Examples of such Widget test matchers are:

  • findsOneWidget → expects exactly one widget “appears” on the “screen”;
  • findsNothing → expects exactly no widget “appears” on the “screen”;
  • findsWidgets → expects more than one one widget “appears” on the “screen”;
  • findsNWidgets(int n) → expects exactly n widget “appears” on the “screen”.

In this case we assert both the text section and button actually “appears” on the “screen”. Hence our assertions are:

      // Check that the title is displayed
expect(find.text(post.title!), findsOneWidget);

// Check that the button is displayed
expect(find.text('See Detail'), findsOneWidget);
expect(find.byType(ElevatedButton), findsOneWidget);

Let’s try running our first test case, if you had done it correctly, it will pass with flying colors.

With our first widget test case out of the way, let’s move on filling in our second test case, that is to see if PostItemWidget button does not appear when item param title field is null.

First, let’s make another instance of Post object that has no title param specified, thus keeping the title field as null:

  final postNoTitle = Post(
userId: 1,
id: 1,
body: 'Test Body',
);

testWidgets(
'PostItemWidget button does not appear when item param title field is null',
(WidgetTester tester) async {
// Build the widget with a null onClick function
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: PostItemWidget(
item: postNoTitle,
onClick: null,
),
),
));

expect(find.byType(ElevatedButton), findsNothing);
},
);

With our postNoTitle variable, then we go on filling in out second test case as such:

Since this test case are mere variation of the first test case, I would explain it shortly. The difference in this test case is that the PostItemWidget item param is initialized with postNoTitle and instead of expecting findsOneWidget when looking up for Widget by type ElevatedButton, we expect findsNothing as there shall be no ElevatedButton ”appearing” on the “screen” when Post instance passed to PostItemWidget with its title field being null.

Let’s try running all widget test cases and if you done this correctly, all test cases shall pass as shown below:

Completing both first and last widget test cases makes our widget test file to look like this:

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:retrofit_dio_example/core/models/post.dart';
import 'package:retrofit_dio_example/ui/widgets/post_item_widget.dart';

void main() {

// Test data
final post = Post(
userId: 1,
id: 1,
title: 'Test Title',
body: 'Test Body',
);
final postNoTitle = Post(
userId: 1,
id: 1,
body: 'Test Body',
);

testWidgets(
'PostItemWidget displays title and a button',
(WidgetTester tester) async {
// Build the widget
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: PostItemWidget(
item: post,
onClick: () {},
),
),
),
);
// Check that the title is displayed
expect(find.text(post.title!), findsOneWidget);
// Check that the button is displayed
expect(find.text('See Detail'), findsOneWidget);
expect(find.byType(ElevatedButton), findsOneWidget);
},
);

testWidgets(
'PostItemWidget button does not appear when item param title field is null',
(WidgetTester tester) async {
// Build the widget with a null onClick function
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: PostItemWidget(
item: postNoTitle,
onClick: null,
),
),
));
expect(find.byType(ElevatedButton), findsNothing);
},
);
}

That’s all for Flutter Widget test quick intro, and in the next article we will cover Widget searching by Widget keys in Widget tests.

Thanks for reading and see you in the next article.

--

--

Madacode

Senior Software Engineer @ Pinhome with 5+ years experience in various technologies including Go and Flutter. All posts are of my own opinion.