Определяющее руководство по модульному, виджетному и интеграционному тестированию приложений Flutter!

Вы когда-нибудь задумывались, почему существует так много учебников и руководств по разработке Flutter и всего несколько источников по тестированию Flutter? Я тоже! В этой статье я расскажу вам, как проводить модульное, виджетное и интеграционное тестирование приложений Flutter.

Введение и приложения, которые мы будем тестировать

Эта статья в блоге будет состоять из трех основных частей: Юнит-тестирование, тестирование виджетов и интеграционное тестирование.

Мы будем тестировать приложение для отслеживания калорий, созданное в предыдущей записи блога (нажмите здесь, чтобы узнать, как создать приложение для отслеживания калорий во Flutter). Вот ссылка на его репозиторий на GitHub

Кроме того, для последующей части раздела тестирования виджетов мы будем использовать образец приложения Flutter под названием form_app, содержащийся в этом репозитории GitHub.

Можете смело клонировать их и следовать за нами.

Вот несколько скриншотов приложений, которые мы будем тестировать:

Рисунок 1: Домашняя страница приложения Calorie Tracker App

Рисунок 2: Экран просмотра дня приложения Calorie Tracker App

Рисунок 3: Экран истории приложения Calorie Tracker App

Рисунок 4: Экран настроек приложения Calorie Tracker App

Рисунок 5: Домашняя страница приложения Form App

Рисунок 6: Демонстрационный экран виджетов формы приложения Form App

Рисунок 7: Экран проверки формы приложения

Юнит-тестирование

Сначала давайте напишем пару модульных тестов для тестирования класса FavoriteFoods в папке models приложения calorie_tracker_app. Для этого нужно добавить следующий код в файл calorie_tracker_app/test/unit-tests/models/favorite-food-tests.dart:

// 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);
    });
  });
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Теперь объясним следующий код:

  • Сначала мы создаем экземпляр favoriteFoods типа FavoriteFoods. Этот класс модели будет объектом тестирования для двух приведенных ниже модульных тестов.

Далее мы напишем несколько модульных тестов, включающих тестирование класса DatabaseService в приложении calorie_tracker_app. Основная задача этого класса — взаимодействие с базой данных Firebase Firestore и манипулирование данными в коллекции foodTracks путем добавления, получения или удаления данных.

Перейдем к файлу calorie_tracker_app/test/unit-tests/database.dart и добавим в него следующий код:

// 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);
    });
  });
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Теперь пояснения к приведенному выше коду:

Ок, это все для модульного тестирования, давайте перейдем к разделу тестирования виджетов.

Тестирование виджетов

Тестирование виджетов можно рассматривать как шаг вперед по сравнению с модульным тестированием, поскольку вместо тестирования одного класса и блока кода, тесты виджетов, как следует из названия, тестируют виджеты. Основным методом, используемым для этого типа тестирования, является класс WidgetTester, который позволяет создавать виджеты и взаимодействовать с ними в тестовой среде. Экземпляры WidgetTester создаются с помощью функции testWidgets(), которая представляет собой функцию, инкапсулирующую каждый отдельный тест виджета.

Теперь давайте протестируем виджет DatePicker в приложении Flutter Calorie Tracker, как показано здесь:

Рисунок 8: Виджет DatePicker на экране просмотра дня

Сначала давайте определим виджет ShowDatePicker, чтобы протестировать его. В папке calorie_tracker_app/lib/src/page/day-view добавим следующий код в файл showDatePicker.dart:

// 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);
                        });
                      }
                    }
                  }),
            ),
          ],
        ),
      ),
    );
  }
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Далее добавим соответствующий тест для вышеуказанного виджета в файл calorie_tracker_app/test/widgets-test/day-view.dart:

// 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;
  });
}
Вход в полноэкранный режим Выход из полноэкранного режима

Этот первый тест testWidgets() проверяет, может ли виджет ShowDatePicker быть отображен. Вызов expect для поиска одного виджета типа ShowDatePicker является подтверждением этого теста.

Теперь давайте посмотрим на другое приложение Flutter, использованное для тестирования: form_app (его репозиторий на GitHub здесь), где мы добавим еще несколько тестов виджетов.

В следующем файле этого приложения: form_app/test/widget-tests/form-widgets.dart, добавим следующий код:

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

Вход в полноэкранный режим Выйти из полноэкранного режима

Вот скриншот поля, которое мы тестируем:

Рисунок 9: Поле Title на демонстрационной странице Form Widgets для приложения form_app

Теперь объясним, что мы только что добавили:

Давайте добавим еще один тест виджета в тот же файл:

// 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);
  });
Вход в полноэкранный режим Выход из полноэкранного режима

Итак, этот тест нацелен на виджет DatePicker, используя метод find.byKey() (подробнее о find.byKey() в документации Flutter).

Вот снимок экрана:

Рисунок 10: Виджет DatePicker в form_app

Затем мы выбираем 15 число того месяца, в котором мы сейчас находимся, и нажимаем кнопку OK, чтобы установить значение этого виджета DatePicker. Наконец, мы проверяем, что значение виджета DatePicker действительно 15, а не 14 или 16. Этот тест виджета является простым способом тестирования виджетов DatePicker.

Давайте добавим один тест виджета для проверки виджета Slider на экране FormWidgetsDemo.

Вот его снимок:

Рисунок 11: Виджет Slider в Form App

Все, что нам нужно сделать, это добавить следующий код в файл widget-tests.dart:

// 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);
  });
Войти в полноэкранный режим Выйти из полноэкранного режима

Также, пока мы не забыли, вышеприведенный тест требует дополнительного метода extention (подробнее о методах extension в документации Dart) в файле form_app/test/extensions/slide-to.dart со следующим кодом:

// 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));
  }
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Я объясню, что делает метод slideToValue() в ближайшее время…

Итак, приведенный выше тест сначала переходит на экран FormWidgetsDemo с помощью тестового приспособления, которое мы определили выше. Затем мы находим виджет Slider путем поиска ключевого значения estimated_value_slider.

Теперь мы используем метод расширения slideToValue() в файле slide-to.dart, чтобы сдвинуть значение виджета Slider на 100. Теперь этот метод потребует некоторых проб и ошибок, чтобы добиться идеального значения. Это связано с тем, что параметр value, который должен указывать значение для сдвига виджета, не сопоставлен один к одному с реальным виджетом Slider (то есть, если передать значение value равное 10, то Slider сдвинется на 50, а значение value равное 20 сдвинет Slider на 100). Вот почему мы передали значение 20 для нашего теста виджета.

Далее мы ждем, пока все запланированные кадры остановятся, используя метод pumpAndSettle() (подробнее об этом методе в документации Flutter), после чего мы используем метод firstWidget() (подробнее об этом методе в документации Flutter), чтобы присвоить виджету Slider переменную slider. Это делается для того, чтобы можно было извлечь значение этого виджета Slider.

Наконец, мы сравниваем значение виджета Slider и убеждаемся, что оно равно 100, используя три метода expect().

Далее добавим тест виджета для проверки сообщений об ошибках от виджетов полей формы на экране Validation.

Вот скриншот сообщений об ошибках валидации, которые мы собираемся протестировать:

Рисунок 12: Ошибки валидации в form_app

Мы можем сделать это, добавив следующий код в файл form_app/test/widget-tests/form-widgets.dart:

// 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);
  });
Вход в полноэкранный режим Выход из полноэкранного режима

Вот объяснение приведенного выше кода:

Ну вот и все для тестирования виджетов, увидимся в следующем разделе!

Интеграционное тестирование

Теперь перейдем к интеграционному тестированию. В приложении calorie_tracker_app есть четыре основных экрана: Домашняя страница, экран просмотра дня, экран истории и экран настроек. Поэтому наши интеграционные тесты будут направлены на эти экраны и их функциональные возможности.

Во-первых, давайте начнем с экрана домашней страницы. Давайте пройдемся по некоторым тестам в файле calorie_tracker_app/test/integration-tests/pages/homepage.dart:

// 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;
});
Вход в полноэкранный режим Выход из полноэкранного режима

Итак, это наш первый интеграционный тест для Домашней страницы, и он в основном рендерит Домашнюю страницу с помощью метода tester.pumpWidget() и проверяет существование виджета Text с текстом "Flutter Calorie Tracker App". Этот текст будет выступать в качестве заголовка для домашней страницы.

Далее рассмотрим другой интеграционный тест, содержащийся в файле calorie_tracker_app/test/integration_tests/pages/day-view.dart:

// 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;
  });
Вход в полноэкранный режим Выход из полноэкранного режима

Этот тест проверяет, выполняет ли нажатие на кнопку "Day View Screen" ожидаемое поведение перехода к экрану Day View. Для этого нужно сначала найти кнопку Day View с помощью метода find.text(), а затем использовать метод tester.tap() для имитации нажатия на нее.

После этого запускается метод pumpAndSettle(), чтобы дождаться остановки всех запланированных кадров.

Наконец, мы используем вызов expect(), чтобы найти виджет TextButton с текстовым значением Today. Нахождение этого виджета однозначно указывает на то, что мы действительно перешли на экран Day View.

Вот еще один интеграционный тест в файле calorie_tracker_app/test/integration_tests/pages/day-view.dart:

// 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;
  });
Вход в полноэкранный режим Выход из полноэкранного режима

Основная цель этого теста — проверить, откроется ли модальное окно при нажатии на соответствующую кнопку для его открытия.

Вот GIF, демонстрирующий эту функцию:

Рисунок 13: Рабочий процесс открытия модала добавления еды

Сначала мы переходим на экран Day View, находим виджет IconButton с иконкой + в качестве значения и нажимаем на нее.

Дождавшись завершения вызова tester.pumpAndSettle(), мы используем метод find.byKey(), чтобы проверить, был ли открыт модал. Метод find.byKey(), о котором вы можете узнать больше в документации Flutter, использует параметр key, указанный в виджетах select, для проверки существования модала на экране. Для более детального описания процесса поиска: метод find.byKey ищет экземпляр класса ValueKey, который используется в качестве значения параметра key в виджетах.

Далее, давайте проверим, создаст ли добавление новой записи о еде с помощью модала Add Food экземпляр FoodTile в нижней части экрана Day View.

Чтобы лучше представить себе этот рабочий процесс, вот GIF, в котором показан весь процесс:

Рисунок 14: Создание рабочего процесса записи дорожки продуктов питания

Вот тест, который проверяет эту функцию, содержащийся в файле calorie_tracker_app/test/integration_tests/pages/day-view.dart:

// 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;
  });
Вход в полноэкранный режим Выход из полноэкранного режима

Продолжая предыдущие интеграционные тесты, мы нажимаем на виджет + IconButton и вводим данные о продукте питания в форму, представленную в модале. Ввод текста осуществляется методом tester.enterText(), подробнее о котором можно узнать из документации Flutter.

Затем, после нажатия кнопки Submit, мы проверяем существование экземпляра FoodTile, который должен содержать данные, введенные в форму. А именно, название блюда Cheese должно присутствовать в первой позиции списка, который отображается на экране Day View.
Мы обязательно проверяем первый элемент в списке продуктов питания через find.text("Cheese").at(0), потому что при многократном выполнении интеграционных тестов есть вероятность, что вновь созданная запись о продукте питания может оказаться за пределами экрана, внизу. Чтобы избежать сложностей, связанных с прокруткой вниз, мы проверяем только первый элемент в списке дорожек еды.

Вот еще один интересный интеграционный тест для экрана Day View в приложении calorie_tracker_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;
  });
Войдите в полноэкранный режим Выход из полноэкранного режима

Итак, этот тест предназначен для проверки функциональности виджета ShowDatePicker по перемещению между датами при нажатии на кнопки со стрелками.

Вот GIF, демонстрирующий эту функциональность:

Рисунок 15: Рабочий процесс переключения даты на экране дневного просмотра

После обычного метода tester.tap(), который имитирует нажатие на кнопки со стрелками влево и вправо, мы переключаемся со вчерашней даты на сегодняшнюю, а затем проверяем наличие строки 'Today' с помощью find.text() для подтверждения этого тестового случая.

И наконец, последнее, но не менее важное: мы можем добавить этот тест-кейс в файл интеграционного теста Day view:

// 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;
  });
Вход в полноэкранный режим Выход из полноэкранного режима

Вот GIF, демонстрирующий, что включает в себя этот тест:

Рисунок 16: Рабочий процесс добавления и удаления записей о продуктах питания

Возможно, самый сложный интеграционный тест, этот тест проверяет возможность добавления и последующего удаления записи о еде. Вот подробное описание этого теста:

  • Сначала открывается модальное окно Add Food и добавляется информация о новом блюде. Причина генерации случайного названия блюда путем добавления случайного числа к строке "Cheese" заключается в предотвращении дублирования названий блюд в списке блюд, что может быть проблематичным при использовании find.text() для поиска вновь созданного блюда в списке блюд.
  • После добавления нового блюда в списке появится новый элемент питания. Теперь, прежде чем искать только что добавленный элемент, мы прокрутим список продуктов до самого низа с помощью метода tester.dragUntilVisible(). Этот метод перетаскивает текущий view до тех пор, пока первый параметр не станет видимым на экране (для получения дополнительной информации о dragUntilVisible() вот ссылка в документации Flutter).
  • Говоря о первом параметре, которым в данном случае является только что добавленная запись о еде, метод find.ancestor() используется в сочетании с find.text() для поиска самой плитки еды.
  • Метод find.text() находит виджет Text, а метод find.ancestor() находит родительский виджет, содержащий этот виджет Text (подробнее о find.ancestor() в документации Flutter). Этим родительским виджетом является сама плитка с едой.
  • Теперь, когда соответствующая плитка с едой найдена, мы можем коснуться ее с помощью метода tester.tap().
  • Затем мы находим кнопку удаления с помощью метода find.byKey() и нажимаем ее.
  • Наконец, мы проверяем, что удаленная плитка с едой не может быть найдена с помощью метода expect().

Хотя мы можем продолжать и продолжать, приводя еще больше примеров интеграционного тестирования, остановимся на этом для краткости.

Заключение

Ух ты! Если вы дошли до этого, поздравляем! Теперь вы знаете некоторые способы тестирования приложений Flutter! Спасибо, что прочитали эту статью в блоге.

Если у вас возникли вопросы или проблемы, пожалуйста, оставьте комментарий в этой статье, и я отвечу вам, когда найду время.

Если вы нашли эту статью полезной, пожалуйста, поделитесь ею и не забудьте следить за мной в Twitter и GitHub, связаться со мной в LinkedIn и подписаться на мой канал YouTube.

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *