DEV Community

Sean Atukorala
Sean Atukorala

Posted on • Updated on

Definitive Guide to Unit, Widget and Integration Testing Flutter Apps!

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:

Calorie Tracker App Homepage

Figure 1: Calorie Tracker App Homepage

Calorie Tracker App Day View screen

Figure 2: Calorie Tracker App Day View screen

Calorie Tracker App History screen

Figure 3: Calorie Tracker App History screen

Calorie Tracker App Settings screen

Figure 4: Calorie Tracker App Settings screen

Form App Homepage

Figure 5: Form App Homepage

Form App Form Widgets Demo screen

Figure 6: Form App Form Widgets Demo screen

Form App Validation 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);
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Now for an explanation of the following code:

  • First we instantiate an instance favoriteFoods of the type FavoriteFoods. 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 a Food instance and then checking for its existence inside the array using the contains() method
  • test("A specified Food instance should be deleted from Favorite Foods array"): This unit test will test the delete functionality of the FavoriteFood class's _favoriteFoodItems array. This is done by first inserting a Food instance into the array and then deleting. Finally, the expect() 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);
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Now for an explanation for the code above:

  • databaseService: This is the DatabaseService class used to communicate with the Firebase Firestore database. It will be used to fetch all foodTrack instances for the purposes of our testing
  • group('testing DatabaseService': The group function is used to combine tests that are similar in functionality. In our case, we will be grouping all unit tests related to testing the DatabaseService class in one group() function
  • test('DatabaseService.getAllFoodTrackData()' should return non-empty list...): This is our first unit test for testing whether we receive a non-empty list of foodTrack instances from the DatabaseService class. If you're wondering why the WidgetsFlutterBinding.ensureInitialized() and await Firebase.initializedApp() methods are called, it is because they are required in order to establish a connection to the Firebase Firestore instance. Once the foodTrack instances are assigned to the List<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 the foodTrack instances that are received from the Firestore database instance. If we are able to create a FoodTrackTask instance, which is a class that uses all the fields in the foodTrack instances stored in the Firestore database, using the data recevied from the first foodTrack 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 the FoodTrackTask instance that is created using the data from the first instance contained in the List<dynamic> getAllFoodTrackData list variable. The assertions for this test will basically check if the newly created foodTrackTask instance's fields contain the data received from the foodTrack 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:

DatePicker widget in Day View screen

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);
                        });
                      }
                    }
                  }),
            ),
          ],
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  });
}
Enter fullscreen mode Exit fullscreen mode

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);
  });
}

Enter fullscreen mode Exit fullscreen mode

Here is a screenshot of the field we're testing:

Title field in Form Widgets Demo page for  raw `form_app` endraw  application

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 the FormWidgetsDemo screen of this application
  • testWidgets(...'Then the TextFormField widget should contain the matching text'): This test method first navigates to the FormWidgetsDemo screen and locates the TextFormField widget with the key: title_text_field. Then the string "Know Thyself" is entered, after which the expect() method would search for a widget with a text value of the string we just entered. The assertion of this widget is done by the expect() method and we look for the coresponding widget by using the find.text() method(more on find.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);
  });
Enter fullscreen mode Exit fullscreen mode

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:

 raw `DatePicker` endraw  widget in  raw `form_app` endraw

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:

Slider widget in Form App

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);
  });
Enter fullscreen mode Exit fullscreen mode

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));
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

Validation Errors in  raw `form_app` endraw

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);
  });
Enter fullscreen mode Exit fullscreen mode

Here is an explanation of the above code:

  • _enterValidationScreen(): This method is a test fixture that navigates to the FormValidationDemo 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 the Validation screen via await _enterValidationScreen(tester) and then tapping the submitButton. After waiting for the scheduled frames to die down, we use find.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;
});
Enter fullscreen mode Exit fullscreen mode

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;
  });
Enter fullscreen mode Exit fullscreen mode

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;
  });
Enter fullscreen mode Exit fullscreen mode

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:

Open Add Food modal Workflow

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:

Create Food Track Entry Workflow

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;
  });
Enter fullscreen mode Exit fullscreen mode

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;
  });
Enter fullscreen mode Exit fullscreen mode

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:

Day View Screen Date Switching Workflow

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;
  });
Enter fullscreen mode Exit fullscreen mode

Here is a GIF demostrating what this test encompases:

Add Then Delete Food Entry Workflow

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 using find.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 current view until the first parameter is visible on screen(for more info on dragUntilVisible() 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 with find.text() to find the food tile itself.
  • The find.text() method would locate the food entry Text widget and then the find.ancestor() method finds the parent widget containing that Text widget(more on the find.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.

Top comments (1)

Collapse
 
vanwormersii profile image
Vanwormersonarcher • Edited

Thank you for addressing a critical aspect of Flutter development! Testing is indeed a crucial but sometimes overlooked area, and I'm looking forward Qiuck Prices to gaining insights from your comprehensive guide on unit, widget, and integration testing for Flutter apps. It's great to see more resources focusing on this important aspect of the development process!

Some comments may only be visible to logged-in visitors. Sign in to view all comments.