Привет всем, кому нравятся Flutter и автотесты!
Когда мы в Surf начали разрабатывать на Flutter, стало интересно посмотреть, что же может автоматизировать сам Flutter? Ведь приложения, которые он создает, кроссплатформенные, а значит и автотесты будут такими... Однако стабильный пакет для работы с E2E и Widget-тестами включили во Flutter-фреймворк не так давно — в начале декабря 2020 года. Поэтому обсудить эту тему интересно.
Сегодня речь пойдет об автотестировании в рамках Flutter-фреймворка в контексте мобильных приложений.
Об особенности тестирования мобильных Flutter-приложений мы уже немного говорили в статье «Тестирование Flutter-приложений: инструменты, преимущества, проблемы».
Автотесты на Flutter
Flutter даёт возможность писать автотесты нативно на языке Dart. Во фреймворке есть:
Unit-тесты, их задача — проверить конкретный модуль системы. Например, убедиться, что контроллер компонента выставляет нужное состояние. Так как это не проверка интерфейса, эмулировать приложение не нужно.
Widget-тесты. Любое Flutter приложение состоит из компонентов Widget: Widget-тесты позволяют эмулировать Widget и проводить с ним необходимое тестирование. Это могут быть целые экраны и отдельные элементы: поля, кнопки, чекбоксы и так далее.
End-to-end (далее — E2E) — тесты, при которых мы полностью загружаем приложение и имитируем действия пользователя в реальной инфраструктуре с реальными сервисами, API и так далее.
Базово о тестах на Dart во Flutter читайте в статье «Тестирование Flutter-приложений: начало».
В Surf Unit-тесты пишут разработчики, потому что они ближе к приложению. Widget- и E2E- тесты пишут автоматизаторы тестирования: оба видов тестов тесно связаны с готовым приложением и проверками для тестирования.
Стратегия имплементации и использования автотестов во Flutter
Для полноценного тестирования нужны и Widget-, и E2E-тесты.
Widget-тесты. Хорошо подходят для запуска на пулл-реквестах. Работают на моковых данных и проходят в несколько раз быстрее.
E2E-тесты. Тестировать приложение на полных пользовательских сценариях и реальном сервере необходимо — иначе сложно утверждать о полной работоспособности системы. При этом запускать их, например, на пулл-реквестах нецелесообразно: E2E-тесты медленные и нестабильные. Их нестабильность — не в том, как они написаны, а в инфраструктуре: всегда может упасть сервер, отвалиться какой-то запрос или же на реальном девайсе появится системный диалог. Всё это уронит тест.
Автотесты приносят максимальную пользу, если запускать их на стабильных фичах как можно чаще.
Пример



Бизнес-сценарий по успешной и неуспешной авторизации покрываем несколькими негативными и позитивными проверками при помощи E2E-тестов. Чтобы проверить работоспособность отдельных модулей систем, нужны Widget-тесты. Минимум по одному на каждый экран, который посещает E2E-сценарий, а также по надобности на каждый Widget-элемент.
Новый пакет
В конце 2020-го во Flutter обновили пакет flutter_test. До этого Flutter работал с Widget- и E2E-тестами отдельно.
Для компонентов Widget был класс WidgetTester. Он позволял загружать Widget и проверять его. Для E2E — класс FlutterDriver. Он загружал приложение и позволял имитировать взаимодействие с интерфейсом. Теперь остался только WidgetTester: Flutter сделали driver-подход deprecated.
Первое, что бросается в глаза, — всё стало проще. E2E-тесты работают теперь через то же API, что и Widget-тесты, — внутри пакета flutter_test через WidgetTester. Поэтому реализовать поддержку Widget- и E2E-тестов одновременно стало проще.
Структура автотестов в Flutter-проекте Surf
.
├── build.yaml                             
├── dart_test.yaml                        
├── integration_test                      
│   ├── credentials                        
│   │   ├── profiles                             
│   │   │   └── *_profile.dart                  
│   │   ├── credentials.dart                     
│   │   └── texts.dart                           
│   ├── features                                 
│   │   └── *.feature                                
│   ├── gherkin       
│   │   └── reports                              
│   │       └── gherkin_reports.json             
│   ├── hooks                                    
│   │   └── *_hook.dart                          
│   ├── step_definitions                             
│   │   └── *_steps.dart                         
│   ├── worlds
│   │   └── *_world.dart                         
│   ├── gherkin_suite_test.dart                       
│   ├── gherkin_suite_test.g.dart                  
│   └── step_definitions_library.dart            
├── lib                                         
│   ├── ui
│   │   └── res
│   │       └── test_keys.dart                   
│   └── main.dart                                
├── test 
│   ├── widget_test                              
│   └── tests                                       
├── test_screens                                 
│   ├── screens
│   │   └── *_screen.dart                        
│   ├── utils                                    
│   │   └── *_utils.dart                         
│   ├── screens_library.dart                     
│   └── test_delays.dart                         
├── pubspec.yaml                                     
└── test_driver                                       
    └── integration_test.dart   
build.yaml — Содержит настройку для build_runner. Основное применение — добавить папки и файлы, которые генератор кода может видеть.
dart_test.yaml — Содержит конфигурацию для Unit- и Widget-тестов. Например, теги для фильтрации тестов.
_profile.dart — Содержит соответствие аккаунтов в сценариях к реальным аккаунтам. Позволяет проще менять аккаунты в сценариях, а также иметь разные профили, если это потребуется.
credentials.dart — Содержит данные от аккаунтов из профилей. Данные можно указывать разные — какие требуются для сценария.
texts.dart — Содержит различные тексты в приложении. Например, тексты снеков ошибок для будущего их использования в тестах. Если в приложении несколько языков, для каждого создаётся отдельный объект.
.feature файлы — Содержат сценарии на языке Gherkin. Каждый шаг этих сценариев имплементируем и выполняем в рамках теста.
Gherkin
Cценарии для E2E-тестов мы в Surf пишем на человекочитаемом языке Gherkin. Благодаря этому любой член команды поймёт, что проверяется и как, — даже если он вообще не знаком с автотестами.
Можно оценивать примерное тестовое покрытие, проводя аналогию с тест-кейсами. Сценарии для написания — брать из тест-кейсов. Если тест зафейлится, тестировщикам проще будет его проверить: они воспользуются обычным сценарием для воспроизведения.
Поэтому для нас было важно, чтобы Gherkin можно было использовать и в автотестах Flutter.
gherkin_reports — Отчёт.
_hook.dart — Hooks, которые использует flutter_gherkin во время выполнения сценариев. Бывают разных видов, выполняются в необходимый момент состояния приложения.
_steps.dart — Файлы, содержащие имплементацию шагов из Gherkin-сценария. Разбиты по разным файлам по смыслу. Вместе их собирает step_definitions_library.dart. В файле есть класс шагов со списком определений steps.
_world.dart — Объекты, которые в flutter_gherkin фреймворке хранят состояние текущего сценария. В _world.dart-файлах мы можем добавить или переопределить логику объектов. Например, дать возможность передавать информацию внутри сценария между шагами.
gherkin_suite_test.dart — Файл, в котором настраивается flutter_gherkin и запускаются тесты.
gherkin_suite_test.g.dart — Файл, который генерируется пакетом build_runner на основе сценариев в feature-файлах. Причина: на текущий момент для работы integration_test нужно, чтобы весь код был сгенерирован до запуска тестов. Содержимое редактировать не стоит.
step_definitions_library.dart — Файл нужен для объединения списков имплементаций шагов в один список, который передастся в stepDefinitions-параметр конфига.
test_keys — Файлы с ключами компонентов Widgets для идентификации.
main — Файл, позволяющий запускать приложение.
test — Папка с Unit- и Widget-тестами.
_screen.dart — Используется, чтобы хранить «локаторы» в одном месте и разделять их по экранам и фичам. А также для переиспользования между Widget- и Е2Е-тестами: синтаксис у файндеров одинаковый. Также в файле экрана могут находиться параметры для контекста или «жесты», которые используются в шагах со свайпами.
_utils.dart — Файлы, содержащие различные вынесенные методы для переиспользования в шагах E2E-тестов или Widget-тестах. В основном — расширения WidgetTester-класса. В таких utils почти всегда требуется инстанс WidgetTester, поэтому вместо передачи его как параметра можно написать расширение. Так будет более понятно и удобно.
screens_library.dart — Файл для группировки экранов. Делает более простой импорт в шагах и Widget-тестах.
test_delays.dart — Разные задержки для запросов и взаимодействий.
pubspec.yaml — Содержит зависимости для flutter-проекта.
integration_test.dart — Большая часть файла — код, нужный для правильной генерации отчёта в формате Cucumber-json. Самая важная часть — в функции main. Она задаёт путь для отчёта и возвращает драйвер для тестов.
Gherkin-сценарий
Существуют готовые фреймворки по работе с Gherkin. Мы выбирали между двумя пакетами: flutter_gherkin и ogurets_flutter. В итоге выбрали flutter_gherkin. Его наиболее активно поддерживали: было видно, что над ним работают. Спойлер: мы не ошиблись — используем его по сей день.
#language: ru
Функциональность: Авторизация
  @auth
  Сценарий: Авто: Авторизация с корректным ОТП
    Когда Я запускаю приложение
    И Я перехожу на таб Библиотека
    И Я тапаю на кнопку Войти
    И Я ввожу рандомный телефон
    И Я тапаю на кнопку далее
    И Я ввожу ОТП код "12345"
    Тогда Я вижу таб Библиотека авторизанта
< . . . >
На новых E2E-тестах приходится генерировать код шагов перед запуском. Парсить Gherkin вживую теперь не выходит: после правок Gherkin-сценариев нужно выполнять codegen. Он генерирует в Dart-коде файл со сценариями. Это вызывает проблемы: например, все сценарии в отчёте попадают в одну фичу.
Возможно, скоро это решат: flutter_gherkin активно поддерживают и улучшают.
Имплементация E2E-сценария
class AuthSteps {
when<ContextualWorld>(
         RegExp(r'Я тапаю на кнопку Войти'),
         (context) async {
           final tester = context.world.appDriver.rawDriver;
           await tester.implicitTap(AuthScreen.loginBtn);
           await tester.pumpAndSettle();
         },
       ),
when1<String, ContextualWorld>(
         RegExp(r'Я ввожу ОТП код {string}$'),
         (code, context) async {
           final tester = context.world.appDriver.rawDriver;
   await tester.pumpUntilVisible(AuthScreen.otpCreateScreen);
           await tester.enterOtp(code, AuthScreen.otpFieldNoError);
           await tester.pumpUntilVisible(AuthScreen.otpRetryScreen);
         },
       ),
< . . . >
Шаг содержит строку, или регулярное выражение, для соотнесения с шагом из Gherkin-файла и функцию, которую нужно выполнить в этом шаге.
Цифра после названия ключевого слова в функции обозначает, сколько будет аргументов в шаге. Например, when1. Их мы передаем вместе с контекстом для функции-тела шага.
Важное уточнение: допустим, есть несколько шагов, похожих друг на друга. Один шаг целиком содержит другой: «Я тапаю на кнопку» и «Я тапаю на кнопку дважды». Это вызывает проблему: определяется имплементация шага, который нашелся первым. В этом случае важно использовать регулярные выражения (как и в других кроссплатформенных средствах). RegExp с $ на конце — RegExp('Я жму на кнопку$') — помогает избежать конфликтов, и явно показывает где конец шага. Больше не возникает ситуация, когда матчер выбрал не то определение шага.
Имплементация Widget-сценария
testWidgets('Максимальная длина поля 11 символов', (WidgetTester tester) async {
// given
      await tester.pumpWidget(GeneralWidgetInit.defaultWrapper(
        const AuthLoginScreen(),
      ));
      await tester.pumpAndSettle();
      await tester.doEnterText(AuthScreen.phoneFld, randomNumber(12));
// then
      final field = tester.widget<TextField>(AuthScreen.phoneFld);
      expect(field.controller.text.length, 11);
    });
< . . . >
 }); // group('Авторизация по телефону')
}
< . . . >
Раньше, чтобы обратиться к элементу, нельзя было определить его по локатору, чтобы тот подходил и для Widget-теста и для E2E-теста. Теперь это доступно автоматически.
Удобно, что можно взять целое свойство Widget — например, длину — и сравнить с ожидаемым результатом. При проверке ограничения поля вводим текст больше 11 символов. На выходе проверяем, что ввелось только 11.
Переиспользование компонентов и методов
Переиспользуемые компоненты
Вынести переиспользуемые компоненты теперь совсем просто. Оба вида тестов используют один класс — WidgetTester. Поэтому нет никаких проблем с работой селекторов или общих функций. Выносим их отдельно, например, и не знаем горя.
Итого: переиспользуем между Widget- и E2E-тестами селекторы (Finder-класс), функции и жесты.
class AuthScreen {
 // Экран ввода пин-кода при авторизации
 static Finder pinScreen = find.byKey(AuthTestKeys.pinScreen);
 // Экран задания пин-кода
 static Finder pinCreateScreen = find.byKey(AuthTestKeys.pinCreateScreen);
 // Элемент пин-клавиатуры при вводе пин-кода при авторизации
 static Finder pinNumberLogin(String number) =>
     find.descendant(of: pinScreen, matching: find.byKey(AuthTestKeys.pinKeyboardBtn(number)));
 // Поле Логин на экране авторизации
 static Finder loginField = find.byKey(AuthTestKeys.loginField);
< . . . >
}
Ключи/Keys
abstract class AuthTestKeys
{
  /// ключ поля логина на экране авторизации
static const Key loginField = Key('fld_auth_login');
}
class AuthScreen {
  /// поле Логин на экране авторизации
  static Finder loginField = find.byKey(AuthTestKeys.loginField);
}
Все ключи удобнее хранить в папке приложения. Так разработчики и тестировщики всегда знают, для чего какой ключ нужен. Если ключ не тестовый, то, скорее всего, он для чего-то нужен.
Плюс:
Улучшается читаемость кода.
Автодополнение помогает при составлении локаторов.
Нет ошибок и опечаток в тексте ключей: они уникальные и ссылаются на переменную.
Классы экранов и ключей инициализировать не нужно: физического смысла в них нет.
Переиспользуемые методы
Переиспользуемые методы зеркалят экраны, но предоставляют методы для шагов, чтобы не дублировать код. Почти всегда в utils нужен доступ к WidgetTester, а передавать его как параметр функции не очень красиво, поэтому используем extension.
extension AuthExtendedWidgetTester on WidgetTester {
/// Метод для ввода пин-кода [pin]. Экран задается [pinNumber]  
/// т.к. Finder кнопки зависит от экрана И цифры пина, поэтому Finder получаем в этом методе
  Future<void> enterPin(String pin, Finder Function(String) pinNumber) async {
    final streamPin = Stream.fromIterable(pin.split(''));
    await pumpForDuration(const Duration(milliseconds: 500));
    await for (final String ch in streamPin) {
      await implicitTap(pinNumber(ch));
    }
  }
}
Вызов enterPin через tester. Так сразу понятно, что происходит.
Жесты
Offset scrollDown = Offset(0, -120)
abstract class GeneralGestures {
  /// Направление для scroll - направление движения списка
  /// Направление для flick/swipe etc - направление движения пальца
  // жесты для скролла экранов вниз
  static const Offset scrollDown = Offset(0, -120);
  static const Offset flickUp = Offset(0, -600);
  // жесты для скролла экранов вверх
  static const Offset scrollUp = Offset(0, 120);
  static const Offset flickDown = Offset(0, 600);
}
Часто в тесте нужно свайпать или скроллить. Писать каждый раз const Offset scrollDown = Offset(0, -120) неудобно. Если нужно поменять — громоздко. Поэтому есть файлы с жестами, которые вызываются в шагах.
Например, жесты для скролла экранов в общих жестах.
Функции
Мы дописали новые функции для работы с автотестами во Flutter, чтобы сделать работу более удобной и оптимальной.
Например,
pump
В Widget- и E2E-тестах между действиями нужно совершать pump, который постоянно помогает приложению крутиться дальше. Приходится самостоятельно указать тестовой среде, что требуется перестроить Widget. Не делаешь Pump — приложение стоит на месте.
По умолчанию во flutter_test есть:
pump() — Метод, который запускает обработку кадра после заданной задержки.
pumpAndSettle() — Ждёт завершения отрисовки новых кадров до таймаута. Этот метод будет крутиться без конца, если у анимации нет завершения.
Такие методы в целом пойдут, но встречаются ситуации, которые не работают для них. Поэтому мы создали несколько вспомогательных методов для удобства.
pumpUntil
/// Функция для pump пока не будет обнаружен Widget
Future<bool> pumpUntilVisible(Finder target,
    {Duration timeout = _defaultPumpTimeout, bool doThrow = true}) async {
  bool condition() => target.evaluate().isNotEmpty;
  final found = await pumpUntilCondition(condition, timeout: timeout);
  // ignore: only_throw_errors
  if (!found && doThrow) throw TestFailure('Target was not found ${target.toString()}');
  return found;
}
/// Метод для того, чтобы делать pump, пока не произойдет условие [condition]
Future<bool> pumpUntilCondition(bool Function() condition,
    {Duration timeout = _defaultPumpTimeout}) async {
  final times = (timeout.inMicroseconds / _minimalPumpDelay.inMicroseconds).ceil();
  for (var i = 0; i < times; i++) {
    if (condition()) {
      await pumpForDuration(_minimalInteractionDelay);
      return true;
    }
    await pump(_minimalPumpDelay);
  }
  return false;
}
Такой метод делает pump, пока не произойдет определенное условие или таймаут.
Вы могли заметить _minimalPumpDelay переменную. Однажды мы столкнулись с ситуацией, когда тесты начинают видеть кнопку с другого экрана, хотя ещё не завершилась анимация перехода на этот экран. Если делать pump() в цикле с нахождением Widget, это может забить весь поток, и приложение начнёт тупить. Тесты проходят долго и иногда флакуют. Логика не успевает отработать, потому что заблочена сложными локаторами.
Решение: добавили задержку 50 мс на pump в циклах. Всё наладилось. Поэтому _mininalPumpDelay нужен, чтобы анимации успевали завершаться после нахождения Widget.
implicitTap
Future<void> implicitTap(Finder finder, {Duration duration}) async {
    final found = await pumpUntilVisible(finder, duration: duration ?? _defaultPumpTimeout);
    if (!found) {
      // ignore: only_throw_errors
      throw TestFailure(finder.toString());
Метод implicitTap работает с использованием удобных pumpUntil* методов. implicitTap — это «неявный» тап. Он будет ждать через метод pumpUntilVisible свой Finder и тапать на него. Если таймаут — выкидывает ошибку теста.
Context
 class ContextualWorld extends FlutterWidgetTesterWorld {
  Map<String, dynamic> scenarioContext = <String, dynamic>{};
  @override
  void dispose() {
    super.dispose();
    scenarioContext.clear();
  }
  T getContext<T>(String key) {
    return scenarioContext[key] as T;
  }
  void setContext(String key, dynamic value) {
    scenarioContext[key] = value;
  }
  Future<void> attachScreenshot() async {
    final bytes = await appDriver.screenshot();
    attach(base64Encode(bytes), 'image/png');
  }
}
В некоторых сценариях требуется запоминать данные. Например: открыли деталку карты, скрыли карту и хотим найти её в скрытых. Для этого нужно запомнить id продукта — то есть данные для поиска.
Хотелось хранить любые параметры, поэтому создали собственный World — ContextualWorld. Для удобства есть два метода-хелпера:
setContext задаёт значение для ключа в контекст.
getContext возвращает значение. Важно getContext сделать с дженериком, чтобы не писать каждый раз в шагах, например, as String. Контекст может быть динамический, поэтому нужно кастить типы. С помощью метода можно просто указать в <T> тип <String>.
Параметры для переиспользования хранятся в файлах экранов. Это позволяет пользоваться читаемыми параметрами вроде AuthParams.user и не писать каждый раз строки ключей.
Таким образом мы переписали авторизацию в сценариях. Есть шаг «Допустим Я использую аккаунт "account"», он запоминает user. Все шаги, которые делают действия с авторизацией, используют этот параметр из контекста. В сценариях не нужно писать ввожу пароль юзера "user", ввожу ОТП юзера "user" и тому подобное.
Hooks
Hooks (далее - Хуки) — перехватчики для простого управления жизненным циклом компонентов Widget.
Сброс состояния приложения
В некоторых больших проектах используют GetIt — глобальное хранилище состояний. Оно инициализируется один раз при запуске. Но тексты могут запускать приложение несколько раз за сессию, это приводит к конфликтам. Тип хука onBeforeScenario сбрасывает GetIt, и запуск приложения не падает.
onAfterScenarioWorldCreated сбрасывает все хранилища приложения перед новым сценарием, потому что сценарий может упасть, и тогда хук не выполнится.
Скриншот при падении теста
Есть хук, который делает скриншот при падении теста. Это удобно, когда выполняешь весь прогон и смотришь отчёт.
Перезапуск
when<ContextualWorld>(
      RegExp(r'Я перезапускаю приложение$'),
          (context) async {
        final navigator = devGetIt<GlobalKey<NavigatorState>>().currentState;
        unawaited(navigator.pushAndRemoveUntil(SplashScreenRoute(), (_) => false));
        final tester = context.world.appDriver.rawDriver;
        await tester.pumpUntilVisibleAny([AuthScreen.loginField, AuthScreen.pinScreen]);
      },
    ),
Ещё одна проблема — необходимость перезапускать приложение во время сценария. Например, при установке пин-кода и авторизации по нему.
Решили её весьма элегантно: получаем текущий навигатор, возвращаем его до Route-сплеш-экрана — это самый первый экран. Не буквальный перезапуск, но происходит быстрее, и приложение работает как с перезапуском.
Steps
Фреймворк принимает шаги списком сущностей StepDefinitionGeneric: нужно либо писать все шаги в одном списке, либо называть их каждый раздельно, потом объединять в список и так далее.
Мы делим шаги на экраны. В каждом файле шагов есть класс с параметром steps: в него кладем шаги. Если шаги однотипные, например, «Я вижу деталку дебетовой карты» и «Я вижу деталку кредитной карты», выносим их в отдельную приватную переменную и потом мержим в список. Потом все шаги всех экранов мержатся в один список в файле step_definitions_library.dart и импортируются в конфиге.
static Iterable<StepDefinitionGeneric> get steps => [
        ..._SeeProductDetails.steps,
        <otherStepsHere>,
]
---
/// Шаги "Я вижу" детальных экранов продуктов
class _SeeProductDetails {
 static final Iterable<StepDefinitionGeneric> steps = [
   then<ContextualWorld>(
     RegExp(r'Я вижу ...
Покрытие
Измерять тестовое покрытие можно:
Widget-элементами кода. В этом помогут сервисы типа Сoverage.
По аналогии с ручными кейсами: компонентными и сценарными.

Отчёты
Для отчётов используем Cucumber-html-reporter. Фреймворк завершает прогон и создаёт json-файл в формате Сucumber. С помощью стороннего приложения можно получить интерактивный html-отчёт: его удобно смотреть и можно использовать как артефакт. Достаточно положить файл в папку с этим Node-приложением и запустить: сгенерируется файл.

Проверки
Любой тест начинается со сценария. Без понимания, как именно мы пишем сценарии, слабо понятен весь профит. Зачем используем оба вида тестов? Почему оба типа автотестов пишут QA, а не разработчики?
У нас есть два вида проверок:
бизнес-сценарии, которыми покрываем критические пути приложения;
компонентные, которыми проверяем работоспособность каждого элемента.
Бизнес-сценарии
Пример бизнес-сценария
Запустить приложение -> Оказываемся на экране Авторизации -> Аккаунт имеется, Верно ввести логин и пароль -> Авторизоваться успешно -> Попадаем на экран каталога -> Открыть детали любой книги -> Добавить книгу в корзину -> Значок “в корзину” меняется на кнопку “перейти в корзину” -> Перейти в корзину -> В корзине лежит добавленная книга -> Перейти к оформлению заказа -> Выбрать оплату при получении, заполнить данные по адресу -> Оформить успешно -> Заказ создан, книга из корзины пропала, но имеется запись на экране Мои заказы
Из бизнес-сценариев получаем возможность качественно осуществлять регрессивное тестирование и проверить приложение в тех местах, которые часто использует пользователь.
Пример
Есть приложения книжного магазина с возможностью заказа в авторизованной зоне. Для него такими сценариями будут:
-> авторизоваться успешно и неуспешно (например, ввести неверный пароль),
-> выбрать книгу (из поиска или каталога),
-> дойти до корзины и оформить заказ с оплатой.
Плюс различные комбинации, отражающие критические пути приложения.
Компонентные проверки
Берём каждый экран фичи и элемент экрана, работаем с ним как с самостоятельным. Полностью покрываем проверками: например, если имеем дело с полем, должны проверить его отображение, ограничение на вводимые символы, валидацию и так далее.
Коротко коснемся того, как разбивать фичу на детальные компоненты.
Мобильные приложения состоят из экранов, шторок, попапов, полей ввода, кнопок, чек-боксов, свитчеров и так далее… Именно от этого мы отталкивались изначально: решили разбить все составляющие на элементы и места, где они находятся. Далее — углубиться в клиент-серверную архитектуру приложения (без неё сегодня никуда).
Данные на экране почти всегда берутся из запроса. Он может отдать корректный и некорректный ответы. Стоит проверять экран отдельно и в прямом взаимодействии с запросом. Аналогично работает и с элементом.
Статья о том, как именно мы разбиваем фичу на компонентные проверки и что в них входит.
Каждый элемент во Flutter — это Widget, поэтому тестирование Widget-элементов мапится на компонентные тесты, которые детально покрывают каждый элемент или экран. Целые пользовательские сценарии отлично мапятся на E2E-тесты.
Думаю, теперь явно понятно, почему оба вида тестов удобнее писать именно QA-отделу: все сценарии нужны для ручных активностей. Widget- и E2E-тесты способны облегчают работу ручного тестирования, например, при регрессе или полном full-тестировании.
Плюсы нашей стратегии
Стабильные быстрые Widget-тесты. Это позволяет запускать их на pull-requests и предотвращает попадание багов в сборки для тестирования. Их легко поддерживать: всегда видно, если при изменении кода в мобильном приложении тест сломался.
E2E-тесты покрывают пользовательские сценарии. Их можно запускать по ночам и облегчить жизнь ручного тестировщика, покрыв санити- или смоук-прогоны.
Компонентное и интеграционное тестирование. Два вида тестов помогают работать с моковыми данными и с реальным сервером в одном проекте. Такой подход позволяет измерять покрытие кода, сценариев и ТЗ.
Плюсы и минусы такого подхода к автотестам
Плюсы
Тесты одновременно нативные и кроссплатформенные. Активная поддержка фреймворка.
Доступ к архитектуре и сущностям позволяет проставлять локаторы для нужных элементов самостоятельно, никого не привлекая.
Изменения файлов в мобильном приложении всегда видны автотестеру. Разработчики тоже видят компонентные и сценарные тесты и могут вовремя исправить ошибку у себя.
Минусы
Новый фреймворк обновляется по своим часам и не всегда синхронизируется с нативными инструментами. По факту этого минуса нет только у нативных инструментов для автотестирования. А вот у Calabash или Appium он есть.
Хочется отметить, что Flutter-коммьюнити без проблем идет на контакт. Они быстро отвечают и исправляют проблемы.
Тесты находятся внутри мобильного приложения и полностью зависят от него. Сломается приложение — сломаются тесты: например, если версия пакета обновилась и зависимости сломались.
Изменения в коде вынуждают постоянно делать билд (для E2E).
Костыли
Если упал один тест, упадут все сразу. Неудобно, раздражает, приходится писать свою логику, которая явно костыльная.
С прокси во Flutter вообще всё очень интересно. У Dart свой клиент для работы с сетью. Все запросы идут через него, поэтому настройки для работы с прокси нужно вводить внутри приложения. Для этого приходится писать дополнительную логику. Это важно, когда работа с проектами необходима под VPN. На одном из проектов мы подключаем компьютер к VPN, а устройство уже работает по прокси.
Подробнее — в статье «Тестирование Flutter-приложений: инструменты, преимущества, проблемы».
GetIt инициализируется один раз при запуске, и с ним возникают конфликты. В тестах приложение может «запускаться» несколько раз. Поэтому надо сбрасывать GetIt, чтобы оно иницилизировалось заново.
Нативные алерты не поддерживаются. Flutter-тесты не могут взаимодействовать с нативными алертами, поэтому для обхода можем просто при запуске тестов дать определенные разрешения через adb или simctl в файле integration_test.dart. Ишью об этом в репозитории Flutter есть, ждем…
Future<void> main() async {
  integration_test_driver.testOutputsDirectory = 'integration_test/gherkin/reports';
  /// Выдаем права на геолокацию чтобы не было проблем
  await Process.run('xcrun', ['simctl', 'privacy', 'booted', 'grant', 'location-always', <app_packet>]);
  [
    'ACCESS_COARSE_LOCATION',
    'ACCESS_FINE_LOCATION',
  ].forEach((permission) async => Process.run('adb',
      ['shell', 'pm', 'grant', <app_packet>, 'android.permission.$permission']));
  return integration_test_driver.integrationDriver(
    timeout: const Duration(minutes: 120),
    responseDataCallback: writeGherkinReports,
  );
}
Сравнение с существующими технологиями

Во всех фреймворках для реализации автотестов для Flutter есть плюсы и минусы
Мы не успели поработать на Calabash для автотестирования Flutter-приложений, хотя некоторые мысли на этот счёт есть. Теоретически работа возможна, но могут возникнуть проблемы с определением id элемента.
Если вы не выбираете нативные автотесты, стоит определиться, нужны ли, помимо Е2Е, Widget-тесты, и если да, то кто будет их писать.
Во Flutter есть крутой плюс, от которого сносит башню: это возможность обращаться к Widget-элементу когда угодно и брать любую информацию из него.
Субъективно рекомендую автотесты на Flutter, если:
прельщает близкая интеграция с приложением,
не пугает Dart,
хочется изучать новую технологию,
нравится возможность что-то творить самостоятельно,
интересно контрибьютить во Flutter и помогать разработке, а не только заниматься тестированием,
держать всё под своим контролем в рамках бОльшего покрытия.
Мы подготовили проект с базовым набором тестов по текущей архитектуре. По ссылке вы можете склонить проект и попробовать вживую то, что обсуждали в статье:
Спасибо коллеге по QA-отделу Владиславу Воронину за соавторство и подготовку тестового проекта.