Definitive Guide to Unit, Widget and Integration Testing Flutter Apps!
Sean Atukorala
Posted on April 14, 2022
Ever wondered why there are so many Flutter development tutorials and guides and yet only a few sources on Flutter testing? Me too! In this blog post I'll show you how to conduct unit, widget and integration testing for Flutter applications.
Introduction & Apps We'll Be Testing
This blog post will be split into three main parts: Unit testing, Widget testing and Integration testing.
We will be testing a Calorie Tracker Application built in an earlier blog post(click here to learn how to build a Calorie Tracker app in Flutter). Here is its GitHub repo link
Also, for the later part of the Widget Testing section we will be using a sample Flutter application, called form_app
, contained in this GitHub repo
Free feel to clone them and follow along.
Here are some screenshots of the apps we'll be testing:
Figure 1: Calorie Tracker App Homepage
Figure 2: Calorie Tracker App Day View screen
Figure 3: Calorie Tracker App History screen
Figure 4: Calorie Tracker App Settings screen
Figure 5: Form App Homepage
Figure 6: Form App Form Widgets Demo screen
Figure 7: Form App Validation screen
Unit Testing
First let's write a couple of unit tests for testing the FavoriteFoods
class in the models folder of the calorie_tracker_app
application. To do this we'll have to add the following code to the calorie_tracker_app/test/unit-tests/models/favorite-food-tests.dart
file:
// calorie_tracker_app/test/unit-tests/models/favorite-food-tests.dart
import 'package:calorie_tracker_app/src/model/favorite_foods.dart';
import 'package:calorie_tracker_app/src/model/food.dart';
import 'package:test/test.dart';
void main() {
group("Testing Model classes", () {
var favoriteFoods = FavoriteFoods();
test(
"Given that we instantiate a FavoriteFoods instance"
"When new Food instances are added to it"
"Then the FavoriteFoods instance's _favoriteFoodItems List should contain that Food instance",
() {
var newFood = Food(id: 1, food_name: "Sandwich");
favoriteFoods.add(newFood);
expect(favoriteFoods.getFavoriteFoodItems.contains(newFood), true);
});
test(
"Given that we instantiate a FavoriteFoods instance"
"When Food instances are deleted from its _favoriteFoodItems List"
"Then the FavoriteFoods instance's _favoriteFoodItems List should contain not contain that Food instance",
() {
var newFood = Food(id: 2, food_name: "Pasta");
favoriteFoods.add(newFood);
expect(favoriteFoods.getFavoriteFoodItems.contains(newFood), true);
favoriteFoods.remove(newFood);
expect(favoriteFoods.getFavoriteFoodItems.contains(newFood), false);
});
});
}
Now for an explanation of the following code:
- First we instantiate an instance
favoriteFoods
of the typeFavoriteFoods
. This model class will be the subject of testing for the two unit tests below -
test("A new Food instance should be added to Favorite Foods array")
: This unit test will test the_favoriteFoodItems
array by adding aFood
instance and then checking for its existence inside the array using thecontains()
method -
test("A specified Food instance should be deleted from Favorite Foods array")
: This unit test will test the delete functionality of theFavoriteFood
class's_favoriteFoodItems
array. This is done by first inserting aFood
instance into the array and then deleting. Finally, theexpect()
method is used to verify that the deleted item does not exist in the_favoriteFoodItems
array
Next we'll write some unit tests involving the testing of the DatabaseService
class in the calorie_tracker_app
application. This class's main purpose is to communicate with a Firebase Firestore database and manipulate data in the foodTracks
collection by adding, fetching, or deleting data.
Let's navigate to the calorie_tracker_app/test/unit-tests/database.dart
file and add the following code to it:
// calorie_tracker_app/test/unit-tests/database.dart
import "package:calorie_tracker_app/src/services/database.dart";
import "package:calorie_tracker_app/src/utils/constants.dart";
import 'package:flutter_test/flutter_test.dart';
import 'package:firebase_database/firebase_database.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:calorie_tracker_app/src/model/food_track_task.dart';
void main() {
DatabaseService databaseService;
group('testing DatabaseService', () {
test(
"Given that we instantiate a DatabaseService instance"
"When we fetch all foodTrack instances from the Firestore database"
"Then retrieved List should not be empty", () async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
databaseService =
DatabaseService(uid: DATABASE_UID, currentDate: DateTime.now());
List<dynamic> getAllFoodTrackData =
await databaseService.getAllFoodTrackData();
print(getAllFoodTrackData);
expect(getAllFoodTrackData.length > 0, true);
});
test(
"Given that we instantiate a DatabaseService instance"
"When we fetch all foodTrack instances from the Firestore database and instantiate a FoodTrackTask instance using the first element from that List"
"Then the FoodTrackTask instance should contain should valid fields",
() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
databaseService =
DatabaseService(uid: DATABASE_UID, currentDate: DateTime.now());
List<dynamic> getAllFoodTrackData =
await databaseService.getAllFoodTrackData();
dynamic firstFoodTrack = getAllFoodTrackData[0];
FoodTrackTask foodTrack = FoodTrackTask(
food_name: firstFoodTrack["food_name"],
calories: firstFoodTrack["calories"],
carbs: firstFoodTrack["carbs"],
protein: firstFoodTrack["protein"],
fat: firstFoodTrack["fat"],
mealTime: firstFoodTrack['mealTime'],
createdOn: firstFoodTrack['createdOn'].toDate(),
grams: firstFoodTrack["grams"]);
expect(foodTrack.food_name.isEmpty, false);
expect(foodTrack.calories.isNaN, false);
expect(foodTrack.carbs.isNaN, false);
expect(foodTrack.protein.isNaN, false);
expect(foodTrack.fat.isNaN, false);
expect(foodTrack.mealTime.isEmpty, false);
expect(foodTrack.createdOn.isAfter(DateTime.now()), false);
expect(foodTrack.grams.isNaN, false);
});
});
}
Now for an explanation for the code above:
-
databaseService
: This is theDatabaseService
class used to communicate with the Firebase Firestore database. It will be used to fetch allfoodTrack
instances for the purposes of our testing -
group('testing DatabaseService'
: Thegroup
function is used to combine tests that are similar in functionality. In our case, we will be grouping all unit tests related to testing theDatabaseService
class in onegroup()
function -
test('DatabaseService.getAllFoodTrackData()' should return non-empty list...)
: This is our first unit test for testing whether we receive a non-empty list offoodTrack
instances from theDatabaseService
class. If you're wondering why theWidgetsFlutterBinding.ensureInitialized()
andawait Firebase.initializedApp()
methods are called, it is because they are required in order to establish a connection to the Firebase Firestore instance. Once thefoodTrack
instances are assigned to theList<dynamic> getAllFoodTrackData
variable, we assert that its length must be greater than zero in order for the unit test to pass -
test('First element of the list returned by DatabaseService.getAllFoodTrackData()...')
: This test is designed to test the fields of thefoodTrack
instances that are received from the Firestore database instance. If we are able to create aFoodTrackTask
instance, which is a class that uses all the fields in thefoodTrack
instances stored in the Firestore database, using the data recevied from the firstfoodTrack
instance in the list that we've retrived, then we know that the instances in the database have all the fields that are required by the application(or at least the first one does ;)). The setup looks identical to the first unit test with the only difference being the creation of theFoodTrackTask
instance that is created using the data from the first instance contained in theList<dynamic> getAllFoodTrackData
list variable. The assertions for this test will basically check if the newly createdfoodTrackTask
instance's fields contain the data received from thefoodTrack
instance in the Firestore database
Ok that's it for unit testing, let's move on to the Widget Testing section
Widget Testing
Widget testing can be considered one step up from unit testing because instead of testing a single class and block of code, widget tests, as the name implies, tests widgets. The primary method used for this type of testing is the WidgetTester
class, which allows for the building and interacting with widgets in a test environment. WidgetTester
instances are created by using the testWidgets()
function, which are the functions that encapsulate each individual widget test.
Now let's test the DatePicker
widget in the Flutter Calorie Tracker application, as shown here:
Figure 8: DatePicker widget in Day View screen
First let's define a ShowDatePicker
widget in order to test it. In the calorie_tracker_app/lib/src/page/day-view
folder we'll add the following code to the showDatePicker.dart
file:
// calorie_tracker_app/lib/src/page/day-view/showDatePicker.dart
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
class ShowDatePicker extends StatefulWidget {
@override
_ShowDatePicker createState() => _ShowDatePicker();
}
class _ShowDatePicker extends State<ShowDatePicker> {
Color _rightArrowColor = Color(0xffC1C1C1);
Color _leftArrowColor = Color(0xffC1C1C1);
DateTime _value = DateTime.now();
DateTime today = DateTime.now();
Future _selectDate() async {
DateTime? picked = await showDatePicker(
context: context,
initialDate: _value,
firstDate: new DateTime(2019),
lastDate: new DateTime.now(),
builder: (BuildContext context, Widget? child) {
return Theme(
data: ThemeData.light().copyWith(
primaryColor: const Color(0xff5FA55A), //Head background
),
child: child!,
);
},
);
if (picked != null) setState(() => _value = picked);
_stateSetter();
}
void _stateSetter() {
if (today.difference(_value).compareTo(Duration(days: 1)) == -1) {
setState(() => _rightArrowColor = Color(0xffEDEDED));
} else
setState(() => _rightArrowColor = Colors.white);
}
String _dateFormatter(DateTime tm) {
DateTime today = new DateTime.now();
Duration oneDay = new Duration(days: 1);
Duration twoDay = new Duration(days: 2);
String month;
switch (tm.month) {
case 1:
month = "Jan";
break;
case 2:
month = "Feb";
break;
case 3:
month = "Mar";
break;
case 4:
month = "Apr";
break;
case 5:
month = "May";
break;
case 6:
month = "Jun";
break;
case 7:
month = "Jul";
break;
case 8:
month = "Aug";
break;
case 9:
month = "Sep";
break;
case 10:
month = "Oct";
break;
case 11:
month = "Nov";
break;
case 12:
month = "Dec";
break;
default:
month = "Undefined";
break;
}
Duration difference = today.difference(tm);
if (difference.compareTo(oneDay) < 1) {
return "Today";
} else if (difference.compareTo(twoDay) < 1) {
return "Yesterday";
} else {
return "${tm.day} $month ${tm.year}";
}
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
// width: 250,
body: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Expanded(
child: IconButton(
key: Key("left_arrow_button"),
icon: Icon(Icons.arrow_left, size: 25.0),
color: _leftArrowColor,
onPressed: () {
setState(() {
_value = _value.subtract(Duration(days: 1));
_rightArrowColor = Colors.white;
});
},
),
),
Expanded(
child: TextButton(
// textColor: Colors.white,
onPressed: () => _selectDate(),
// },
child: Text(_dateFormatter(_value),
style: TextStyle(
fontFamily: 'Open Sans',
fontSize: 18.0,
fontWeight: FontWeight.w700,
)),
),
),
Expanded(
child: IconButton(
key: Key("right_arrow_button"),
icon: Icon(Icons.arrow_right, size: 25.0),
color: _rightArrowColor,
onPressed: () {
if (today.difference(_value).compareTo(Duration(days: 1)) ==
-1) {
setState(() {
_rightArrowColor = Color(0xffC1C1C1);
});
} else {
setState(() {
_value = _value.add(Duration(days: 1));
});
if (today
.difference(_value)
.compareTo(Duration(days: 1)) ==
-1) {
setState(() {
_rightArrowColor = Color(0xffC1C1C1);
});
}
}
}),
),
],
),
),
);
}
}
Next let's add the corresponding test for the above widget in the calorie_tracker_app/test/widgets-test/day-view.dart
file:
// calorie_tracker_app/test/widgets-test/day-view.dart
void main() {
testWidgets(
"Given that ShowDatePicker widget in Day View screen is tested"
"When the ShowDatePicker widget is rendered"
"Then it should be found when searching by find.byType()",
(WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
await tester.pumpWidget(ShowDatePicker());
expect(find.byType(ShowDatePicker), findsOneWidget);
debugDefaultTargetPlatformOverride = null;
});
}
This first testWidgets()
test is testing whether the ShowDatePicker
widget is able to be rendered. The expect
call of looking for one widget of the type ShowDatePicker
is what validates this testcase.
Now let's look at the other Flutter application used for testing: form_app
(its GitHub repo here) where we'll add some more widget tests.
In this app's following file: form_app/test/widget-tests/form-widgets.dart
, let's add the following code:
// form_app/test/widget-tests/form-widgets.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:form_app/src/http/mock_client.dart';
import 'package:form_app/src/sign_in_http.dart';
import 'package:form_app/src/form_widgets.dart';
void main() {
Future<void> _enterFormWidgetsScreen(WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(
home: FormWidgetsDemo(),
));
await tester.pumpAndSettle();
}
testWidgets(
'Given the user navigates to the Form Widgets screen'
'When the user types into the title text field'
'Then the TextFormField widget should contain the matching text',
(WidgetTester tester) async {
await _enterFormWidgetsScreen(tester);
var titleTextFormField = find.byKey(ValueKey("title_text_field"));
await tester.enterText(titleTextFormField, "Know Thyself");
await tester.pumpAndSettle();
expect(find.text("Know Thyself"), findsOneWidget);
expect(find.text("Know Thyselves"), findsNothing);
});
}
Here is a screenshot of the field we're testing:
Figure 9: Title field in Form Widgets Demo page for form_app
application
Now for an explanation of what we just added:
-
_enterFormWidgetsScreen()
: This method can be considered a test fixture because it sets up the environment that is to be used for testing. In this case, we navigate to theFormWidgetsDemo
screen of this application -
testWidgets(...'Then the TextFormField widget should contain the matching text')
: This test method first navigates to theFormWidgetsDemo
screen and locates theTextFormField
widget with the key:title_text_field
. Then the string"Know Thyself"
is entered, after which theexpect()
method would search for a widget with a text value of the string we just entered. The assertion of this widget is done by theexpect()
method and we look for the coresponding widget by using thefind.text()
method(more onfind.text()
in the Flutter docs)
Let's add one more widget test to the same file:
// form_app/test/widget-tests/form-widgets.dart
testWidgets(
'Given the user navigates to the Form Widgets screen'
'When the user selects a date from the DatePicker'
'Then the DatePicker\'s value should be the picked date',
(WidgetTester tester) async {
await _enterFormWidgetsScreen(tester);
var datePickerFieldEditButton =
find.byKey(ValueKey("form_date_picker_edit"));
await tester.tap(datePickerFieldEditButton);
await tester.pumpAndSettle();
await tester.tap(find.text("15"));
await tester.tap(find.text("OK"));
await tester.pumpAndSettle();
expect(find.textContaining("/15/"), findsOneWidget);
expect(find.textContaining("/14/"), findsNothing);
expect(find.textContaining("/16/"), findsNothing);
});
So this test is targeting the DatePicker
widget using the find.byKey()
method(more on find.byKey()
in the Flutter docs).
Here is a screenshot of it:
Figure 10: DatePicker
widget in form_app
Then we select the 15th day of whatever month we're currently in and tap the OK
button to set the value of this DatePicker
widget. Finally, we validate that the value of the DatePicker
widget is actually 15
and not 14
or 16
. This widget test is a simple way to test DatePicker
widgets.
Let's add one widget test to test the Slider
widget in the FormWidgetsDemo
screen.
Here is a screenshot of it:
Figure 11: Slider widget in Form App
All we have to do is add the following code to the widget-tests.dart
file:
// form_app/test/widget-tests/form-widgets.dart
testWidgets(
'Given the user navigates to the Form Widgets screen'
'When the user slides the Estimated Value Slider to a certain value'
'Then the Estimated Value Slider\'s value should be the specified value',
(WidgetTester tester) async {
await _enterFormWidgetsScreen(tester);
var estimatedValueSlider = find.byKey(ValueKey("estimated_value_slider"));
await SlideTo(tester).slideToValue(estimatedValueSlider, 20);
await tester.pumpAndSettle();
Slider slider = tester.firstWidget(estimatedValueSlider);
expect(slider.value, 100);
expect(slider.value < 100, false);
expect(slider.value > 100, false);
});
Also before we forget, the above test requires an extra extention
method(more on extension
methods in the Dart docs) in the form_app/test/extensions/slide-to.dart
file with the following code:
// form_app/test/extensions/slide-to.dart
import 'package:flutter_test/flutter_test.dart';
extension SlideTo on WidgetTester {
Future<void> slideToValue(Finder slider, double value,
{double paddingOffset = 24.0}) async {
final zeroPoint = this.getTopLeft(slider) +
Offset(paddingOffset, this.getSize(slider).height / 2);
final totalWidth = this.getSize(slider).width - (2 * paddingOffset);
final calculatedOffset = value * (totalWidth / 100);
await this.dragFrom(zeroPoint, Offset(calculatedOffset, 0));
}
}
I'll explain what the slideToValue()
method does shortly...
So the above test first navigates to the FormWidgetsDemo
screen using the test fixture we defined above. Then, we locate the Slider
widget by searching for the key value of estimated_value_slider
.
Now we use the slideToValue()
extension method in the slide-to.dart
file to slide the Slider
widget's value to 100
. Now this method will take some trail and error to get the value perfected. This is because the value
parameter which is supposed to indicate the value to slide the widget to, does not map one-to-one with the actual Slider
widget(meaning passing in a value
value of 10
would slide the Slider
to 50
whereas a value
value of 20
would slide the Slider
to 100
). This is why we have the passed in a value of 20
for our widget test.
Next, we wait for all scheduled frames to stop using the pumpAndSettle()
method(more on this method in the Flutter docs), after which we use the firstWidget()
method(more on this method in the Flutter docs) to assign the Slider
widget to a variable called slider
. This is done so that the value of this Slider
widget can be extracted.
Finally, we compare the value of the Slider
widget and make sure it is 100
using three expect()
methods.
Next, let's add a widget test to test the error messages from the form field widgets in the Validation
screen.
Here is a screenshot of the validation error messages we are going to test:
Figure 12: Validation Errors in form_app
We can do this by adding the following code to the form_app/test/widget-tests/form-widgets.dart
file:
// form_app/test/widget-tests/form-widgets.dart
Future<void> _enterValidationScreen(WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(
home: FormValidationDemo(),
));
await tester.pumpAndSettle();
}
...
...
...
testWidgets(
'Given the user navigates to the Validation screen'
'When the user submits the form without any values'
'Then error messages should be shown under the text fields',
(WidgetTester tester) async {
await _enterValidationScreen(tester);
var submitButton = find.byKey(ValueKey("submit_button"));
await tester.tap(submitButton);
await tester.pumpAndSettle();
expect(find.text("Please enter an adjective."), findsOneWidget);
expect(find.text("Please enter a noun."), findsOneWidget);
expect(
find.text("You must agree to the terms of service."), findsOneWidget);
});
Here is an explanation of the above code:
-
_enterValidationScreen()
: This method is a test fixture that navigates to theFormValidationDemo
screen -
testWidgets('... Then error messages should be shown under the text fields')
: This method tests whether the appropriate error messages for having empty text fields upon form submission show up when submitting the form. This is done by first entering theValidation
screen viaawait _enterValidationScreen(tester)
and then tapping thesubmitButton
. After waiting for the scheduled frames to die down, we usefind.text()
to check whether the appropriate error messages are present in the screen.
Ok that's it for widget testing, see you in the next section!
Integration Testing
Now on to integration testing. There are four main screens in the calorie_tracker_app
application: Homepage, Day View screen, History screen, and Settings screen. So our integration tests will be targeting these screens and their functionalities.
First, let's start with the Homepage screen. Let's go through some of the tests in the calorie_tracker_app/test/integration-tests/pages/homepage.dart
file:
// calorie_tracker_app/test/integration_tests/pages/homepage.dart
testWidgets(
"Given the user opens the app"
"When the user is shown the homepage"
"Then the user is shown the homepage title", (WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
await SharedPreferencesService().init();
await tester.pumpWidget(CalorieTrackerApp());
expect(find.text("Flutter Calorie Tracker App"), findsOneWidget);
debugDefaultTargetPlatformOverride = null;
});
So this is our first integration test for the Homepage, and it basically renders the Homepage using the tester.pumpWidget()
method and checks for the existence of the Text widget with the text "Flutter Calorie Tracker App"
. This text will act as the title for the Homepage.
Next, let's examine a different integration test contained in the calorie_tracker_app/test/integration_tests/pages/day-view.dart
file:
// calorie_tracker_app/test/integration_tests/pages/day-view.dart
testWidgets(
"Given user opens the app"
"When user taps the Day View Screen button"
"Then Day View Screen is shown", (WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
await SharedPreferencesService().init();
await tester.pumpWidget(CalorieTrackerApp());
final Finder dayViewButton = find.text("Day View Screen");
await tester.tap(dayViewButton, warnIfMissed: true);
await tester.pumpAndSettle();
expect(find.text("Today"), findsOneWidget);
debugDefaultTargetPlatformOverride = null;
});
This test validates whether tapping on the "Day View Screen"
button does the expected behavior of navigating to the Day View screen. This is done through first finding the Day View button via the find.text()
method and then using the tester.tap()
method to simulate tapping on it.
Afterwards, a pumpAndSettle()
method is issued to wait for all scheduled frames to stop.
Finally, we use the expect()
call to find a TextButton
widget with a text value of Today
. Finding this widget would definitively indicate that we have indeed navigated to the Day View screen.
Here is another integration test in the calorie_tracker_app/test/integration_tests/pages/day-view.dart
file:
// calorie_tracker_app/test/integration_tests/pages/day-view.dart
testWidgets(
"Given user opens the Day View screen"
"When user taps the Add Food button"
"Then Add Food modal opens", (WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
await SharedPreferencesService().init();
await tester.pumpWidget(CalorieTrackerApp());
final Finder dayViewButton = find.text("Day View Screen");
await tester.tap(dayViewButton, warnIfMissed: true);
await tester.pumpAndSettle();
await tester.tap(find.byKey(ValueKey("add_food_modal_button")),
warnIfMissed: true);
await tester.pumpAndSettle();
expect(find.byKey(ValueKey("add_food_modal")), findsOneWidget);
debugDefaultTargetPlatformOverride = null;
});
This test's main purpose is to check whether a modal would open when clicking the appropriate button to open it.
Here is a GIF demonstrating this feature:
Figure 13: Open Add Food modal Workflow
First, we navigate to the Day View screen, locate the IconButton
widget with a +
icon as its value and then tap it.
After waiting for a tester.pumpAndSettle()
call to complete, we use the find.byKey()
method to check whether the modal has been opened. The find.byKey()
method, which you can learn more about in the Flutter docs, uses the key
parameter specified in select widgets to check for the modal's existence on the screen. To elaborate this finding process further: the find.byKey
method searches for a ValueKey
class instance that is used as the key
parameter value in widgets.
Next up, let's test whether adding a new food entry via the Add Food modal would create a FoodTile
instance in the bottom portion of the Day View screen.
To give a better idea of this workflow, here is a GIF that goes through the process:
Figure 14: Create Food Track Entry Workflow
Here is the test that is validating this feature, contained in the calorie_tracker_app/test/integration_tests/pages/day-view.dart
file:
// calorie_tracker_app/test/integration_tests/pages/day-view.dart
testWidgets(
"Given user opens the Day View Screen"
"When user submits the Add Food modal form"
"Then a new FoodTrack instance is created", (WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
await SharedPreferencesService().init();
await tester.pumpWidget(CalorieTrackerApp());
final Finder dayViewButton = find.text("Day View Screen");
await tester.tap(dayViewButton, warnIfMissed: true);
await tester.pumpAndSettle();
await tester.tap(find.byKey(ValueKey("add_food_modal_button")),
warnIfMissed: true);
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(ValueKey("add_food_modal_food_name_field")), "Cheese");
await tester.enterText(
find.byKey(ValueKey("add_food_modal_calorie_field")), "500");
await tester.enterText(
find.byKey(ValueKey("add_food_modal_carbs_field")), "15");
await tester.enterText(
find.byKey(ValueKey("add_food_modal_protein_field")), "25");
await tester.enterText(
find.byKey(ValueKey("add_food_modal_fat_field")), "20");
await tester.enterText(
find.byKey(ValueKey("add_food_modal_grams_field")), "20");
await tester.tap(find.byKey(ValueKey("add_food_modal_submit")));
await tester.pumpAndSettle();
expect(find.text("Cheese").at(0), findsOneWidget);
debugDefaultTargetPlatformOverride = null;
});
Picking up from the previous integration tests, we tap the +
IconButton
widget and enter the details of a food item in the form that is presented in the modal. The entering of text is done by the tester.enterText()
method, more details of which can be learned through the Flutter docs.
Then, after hitting the Submit
button we check for the existence of a FoodTile
instance that should contain the data entered in the form. Namely, the food name Cheese
should be present in the first position of the list that is shown in the Day View screen.
We make sure to explicitly check the first element in the food list, via find.text("Cheese").at(0)
, because as integration tests are run multiple times there are chances that the newly created food entry might render off screen, toward the bottom. In order to avoid complications of having to scroll down, we only check the first item in the food track list.
Here's another interesting integration test for the Day View screen in the calorie_tracker_app
app:
// calorie_tracker_app/test/integration_tests/pages/day-view.dart
testWidgets(
"Given user opens the Day View screen"
"When the user taps the Left Arrow Button then Right Arrow Button"
"Then DatePicker's value changes from Yesterday to Today",
(WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
await SharedPreferencesService().init();
await tester.pumpWidget(CalorieTrackerApp());
final Finder dayViewButton = find.text("Day View Screen");
await tester.tap(dayViewButton, warnIfMissed: true);
await tester.pumpAndSettle();
await tester.tap(find.byKey(ValueKey("left_arrow_button")),
warnIfMissed: true);
await tester.pumpAndSettle();
await tester.tap(find.byKey(ValueKey("right_arrow_button")),
warnIfMissed: true);
await tester.pumpAndSettle();
expect(find.text("Today"), findsOneWidget);
debugDefaultTargetPlatformOverride = null;
});
So this test is designed to test the ShowDatePicker
widget's functionality of moving between dates upon tapping the arrow buttons.
Here is a GIF showing this functionality:
Figure 15: Day View Screen Date Switching Workflow
After the usual tester.tap()
method that simulates the tapping on the left and right arrow buttons we switch from yesterday's date to today's date and then check for the string 'Today'
via find.text()
in order to validate this testcase.
Ok, last but not least we can add this testcase in the Day view integration test file:
// calorie_tracker_app/test/integration_tests/pages/day-view.dart
testWidgets(
"Given user opens the Day View Screen"
"When the user taps a Food Tile Delete Button"
"Then that Food Tile is removed from the Food Track List",
(WidgetTester tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
await SharedPreferencesService().init();
await tester.pumpWidget(CalorieTrackerApp());
final Finder dayViewButton = find.text("Day View Screen");
await tester.tap(dayViewButton, warnIfMissed: true);
await tester.pumpAndSettle();
await tester.tap(find.byKey(ValueKey("add_food_modal_button")),
warnIfMissed: true);
await tester.pumpAndSettle();
Random random = new Random();
int randomNumber = random.nextInt(100);
String foodName = "Cheese" + randomNumber.toString();
await tester.enterText(
find.byKey(ValueKey("add_food_modal_food_name_field")), foodName);
await tester.enterText(
find.byKey(ValueKey("add_food_modal_calorie_field")), "500");
await tester.enterText(
find.byKey(ValueKey("add_food_modal_carbs_field")), "15");
await tester.enterText(
find.byKey(ValueKey("add_food_modal_protein_field")), "25");
await tester.enterText(
find.byKey(ValueKey("add_food_modal_fat_field")), "20");
await tester.enterText(
find.byKey(ValueKey("add_food_modal_grams_field")), "20");
await tester.tap(find.byKey(ValueKey("add_food_modal_submit")));
await tester.pumpAndSettle();
await tester.dragUntilVisible(
find.ancestor(
of: find.text(foodName), matching: find.byType(ExpansionTile)),
find.byKey(ValueKey("food_track_list")),
const Offset(0, 500));
await tester.tap(
find.ancestor(
of: find.text(foodName), matching: find.byType(ExpansionTile)),
warnIfMissed: true);
await tester.pumpAndSettle();
await tester.tap(find.byKey(ValueKey("delete_button")), warnIfMissed: true);
await tester.pumpAndSettle();
expect(find.text(foodName), findsNothing);
debugDefaultTargetPlatformOverride = null;
});
Here is a GIF demostrating what this test encompases:
Figure 16: Add Then Delete Food Entry Workflow
Quite possibly our most involved integration test, this test validates the ability to add and then delete a food entry. Here is a detailed breakdown of this test:
- First, the Add Food modal is opened and the details of a new food is added. The reason for generating a random food name, by adding a random number to the string
"Cheese"
, is to prevent duplicate food names in the food list that can be problematic when usingfind.text()
to search for the newly created food entry in the food list. - After adding a new food entry a new food item will appear in the food list. Now before searching for the newly added food entry we make sure to scroll to the bottom of the food list via the
tester.dragUntilVisible()
method. This method drags the currentview
until the first parameter is visible on screen(for more info ondragUntilVisible()
here is the Flutter docs link). - Speaking of the first parameter which in this case is the newly added food entry, the
find.ancestor()
method is used in combination withfind.text()
to find the food tile itself. - The
find.text()
method would locate the food entryText
widget and then thefind.ancestor()
method finds the parent widget containing thatText
widget(more on thefind.ancestor()
in the Flutter docs). This parent widget is the food tile itself. - Now with the appropriate food tile located we can tap it via the
tester.tap()
method. - Afterwards, we find the delete button via the
find.byKey()
method and tap it. - Finally, we validate that the deleted food tile is not able to be found via the
expect()
method.
Ok although we can go on and on with more examples of integration testing, let's stop here for the sake of brevity.
Conclusion
Whew! If you made it this far, congrats! You now know some ways of testing Flutter applications! Thanks for reading this blog post.
If you have any questions or concerns please feel free to post a comment in this post and I will get back to you when I find the time.
If you found this article helpful please share it and make sure to follow me on Twitter and GitHub, connect with me on LinkedIn and subscribe to my YouTube channel.
Posted on April 14, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.