Привет всем, кому нравятся 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

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 и помогать разработке, а не только заниматься тестированием, 

  • держать всё под своим контролем в рамках бОльшего покрытия.

Мы подготовили проект с базовым набором тестов по текущей архитектуре. По ссылке вы можете склонить проект и попробовать вживую то, что обсуждали в статье:

Тестовый проект на Github

Спасибо коллеге по QA-отделу Владиславу Воронину за соавторство и подготовку тестового проекта.

Комментарии (0)