Думаю, многие уже знакомы с Flutter и хотя бы ради интереса запускали простые приложения на нем. Настало время убедиться, что в них все работает как нужно, и в этом нам помогут интеграционные тесты.


Интеграционные тесты на Flutter пишутся при помощи Flutter Driver, для которого есть простой и понятный tutorial на официальном сайте. По своей структуре такие тесты похожи на Espresso из мира Android. Сначала надо найти UI-элементы на экране:


final SerializableFinder button = find.byValueKey("button");

потом выполнить с ними какие-то действия:


driver = await FlutterDriver.connect();
...
await driver.tap(button);

и проверить, что требуемые UI-элементы перешли в нужное состояние:


final SerializableFinder text = find.byValueKey("text");
expect(await driver.getText(text), "some text");

На простом примере, конечно, все выглядит элементарно. Но с ростом тестируемого приложения и увеличением количества тестов не хочется дублировать поиск UI-элементов перед каждым тестом. Кроме того, потребуется структурировать эти UI-элементы, так как экранов может быть очень много. Для этого надо сделать написание тестов удобнее.


Screen Objects


В Android (Kakao) эта проблема решается с помощью группировки UI-элементов с каждого экрана в Screen (Page-Object). Подобный подход можно применить и здесь, только с тем исключением, что в Flutter для выполнения действий с UI-элементами нужен не только Finder (для поиска UI-элемента), но и FlutterDriver (для выполнения действия), поэтому нужно хранить ссылку на FlutterDriver в Screen.


Для определения каждого UI-элемента добавим класс DWidget (D – от слова Dart в этом случае). Для создания DWidget потребуются FlutterDriver, с помощью которого будут выполняться действия над этим UI-элементом, а также ValueKey, который совпадает с ValueKey Flutter виджета из приложения, с которым мы хотим взаимодействовать:


class DWidget {
  final FlutterDriver _driver;
  final SerializableFinder _finder;

  DWidget(this._driver, dynamic valueKey) : _finder = find.byValueKey(valueKey);
...

Вызывать find.byValueKey(…) при ручном создании каждого DWidget неудобно, поэтому в конструктор лучше передавать значение ValueKey, а DWidget сам получит нужный SerializableFinder. Также не очень удобно вручную передавать FlutterDriver при создании каждого DWidget, поэтому можно хранить FlutterDriver в BaseScreen, и передавать его в DWidget, а для создания DWidget добавить новый метод у BaseScreen:


abstract class BaseScreen {
  final FlutterDriver _driver;

  BaseScreen(this._driver);

  DWidget dWidget(dynamic key) => DWidget(_driver, key);
...

Таким образом, создавать классы-Screens и получать UI-элементы в них будет куда проще:


class MainScreen extends BaseScreen {
  MainScreen(FlutterDriver driver) : super(driver);

  DWidget get button => dWidget('button');
  DWidget get textField => dWidget('text_field');
...
}

Избавляемся от await


Еще одна не очень удобная вещь при написании тестов с FlutterDriver – это необходимость добавлять await перед каждым действием:


await driver.tap(button);
await driver.scrollUntilVisible(list, checkBox);
await driver.tap(checkBox);
await driver.tap(text);
await driver.enterText("some text");

Забыть про await – легко, а без него тесты будут работать некорректно, потому что методы driver возвращают Future<void> и при их вызове без await выполняются до первого await внутри метода, а остальная часть метода «откладывается на потом».


Исправить это можно с помощью создания TestAction, который будет «оборачивать» Future, чтобы мы могли дождаться завершения одного действия, прежде чем переходить к следующему:


typedef TestAction = Future<void> Function();

(по сути, TestAction – это любая функция (или лямбда), которая возвращает Future<void>)


Теперь можно легко запускать последовательность TestAction без лишних await:


Future<void> runTestActions(Iterable<TestAction> actions) async {
  for (final action in actions) {
    await action();
  }
}

Используем TestAction в DWidget


DWidget используется для взаимодействия с UI-элементами, и будет очень удобно, если эти действия будут представлять собой TestAction, чтобы их можно было использовать в методе runTestAction. Для этого в классе DWidget будут методы-действия:


class DWidget {
  final FlutterDriver _driver;
  final SerializableFinder _finder;
 ...

  TestAction tap({Duration timeout}) =>
      () => _driver.tap(_finder, timeout: timeout);

  TestAction setText(String text, {Duration timeout}) => () async {
        await _driver.tap(_finder, timeout: timeout);
        await _driver.enterText(text ?? "", timeout: timeout);
      };
 ...
}

Теперь писать тесты можно следующим образом:


class MainScreen extends BaseScreen {
  MainScreen(FlutterDriver driver) : super(driver);

  DWidget get field_1 => dWidget('field_1');
  DWidget get field_2 => dWidget('field_2');
  DWidget field2Variant(int i) => dWidget('variant_$i');
  DWidget get result => dWidget('result');
}

…
final mainScreen = MainScreen(driver);

await runTestActions([
  mainScreen.result.hasText("summa = 0"),
  mainScreen.field_1.setText("3"),
  mainScreen.field_2.tap(),
  mainScreen.field2Variant(2).tap(),
  mainScreen.result.hasText("summa = 5"),
]);

Если потребуется выполнить в runTestActions какое-то действие, не относящееся к DWidget, то нужно просто создать лямбду, которая вернет Future<void>:


await runTestActions([
  mainScreen.result.hasText("summa = 0"),
  () => driver.requestData("some_message"),
  () async => print("some_text"),
  mainScreen.field_1.setText("3"),
]);

FlutterDriverHelper


У FlutterDriver есть несколько методов для взаимодействия с UI-элементами (нажатие, получение и ввод текста, скроллинг и т. д.) и для этих методов у DWidget имеются соответствующие методы, возвращающие TestAction.


Для удобства весь код, описанный в этой статье, опубликован как библиотека FlutterDriverHelper на pub.dev.


Для скролла списков, в которых элементы создаются динамически (например, ListView.builder) у FlutterDriver есть метод scrollUntilVisible:


Future<void> scrollUntilVisible(
  SerializableFinder scrollable,
  SerializableFinder item, {
  double alignment = 0.0,
  double dxScroll = 0.0,
  double dyScroll = 0.0,
  Duration timeout,
}) async { ... }

Этот метод скроллит виджет scrollable в указанном направлении до тех пор, пока виджет item не появится на экране (или пока не наступит timeout). Чтобы не передавать scrollable при каждом скролле, был добавлен класс DScrollItem, который наследует DWidget и представляет собой элемент списка. Он содержит ссылку на scrollable, поэтому при скролле остается только указать dyScroll или dxScroll:


class SecondScreen extends BaseScreen {
  SecondScreen(FlutterDriver driver) : super(driver);
  DWidget get list => dWidget("list");
  DScrollItem item(int index) => dScrollItem('item_$index', list);
}
...

final secondScreen = SecondScreen(driver);
await runTestActions([
  secondScreen.item(42).scrollUntilVisible(dyScroll: -300),
  ...
]);

Во время тестов можно делать скриншоты приложения, и в FlutterDriverHelper есть Screenshoter, который сохраняет скриншоты в нужную папку с указанием текущего времени и умеет работать с TestAction.


Другие проблемы и их решения


  • мне не удалось найти стандартный способ нажимать на кнопки в диалогах выбора времени/даты — приходится использовать TestHooks. Также TestHooks могут пригодиться для изменения текущего времени/даты во время выполнения теста.
  • в выпадающем списке у DropdownButtonFormField надо указывать key не у DropdownMenuItem, а у child этого DropdownMenuItem, иначе Flutter Driver не сможет его найти. Кроме того, скроллинг в выпадающем списке пока что не работает (Issue на github.com).
  • метод FlutterDriver.getCenter возвращает Future<DriverOffset>, но DriverOffset не входит в публичный API (Issue на github.com)
  • есть еще несколько проблемных и не очевидных вещей, решение которых уже существует. О них можно прочитать в замечательной статье. Особенно полезными оказались возможность запускать тесты на десктопе и сбрасывать состояние приложения перед началом каждого теста.
  • запускать тесты можно с помощью с Github Actions. Подробнее тут.

TODO


В качестве TODO на будущее для FlutterDriverHelper можно назвать:


  • автоматический скролл до нужного элемента списка, если в момент обращения к нему он не виден на экране (как это сделано в библиотеке Kaspresso для Android). Если получится, то даже в обоих направлениях.
  • interceptors для действий, выполняемых с Dwidget или DscrollItem.

Комментарии и конструктивная обратная связь приветствуются.


Update (15.01.2020): в версии 1.1.0 TestAction стал классом, с полем String name. И благодаря этому добавилось логирование всех выполняемых действий в методе runTestActions.