Думаю, многие уже знакомы с 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
.
mkulesh
Спасибо за статью! Я из текста не смог понять (так как сам такие тесты ещё никогда не делал), они запускаются на эмуляторе или на реальном Андроид-устройстве? Если на реальном, то возможно ли их запускать на реальном iOS, например, на реальном iPad? Если да, то легко решается задача создания видео для приложения. Само видео можно писать штатными средствами macOS, но мне пока не хватает автоматизации сценария. И вот тут этот инструмент мог бы и пригодится.
qwert2603 Автор
Спасибо за отзыв! Такие тесты могут запускаться и на эмуляторах и на реальных Android/iOS девайсах. И даже на десктопе (правда, только с master ветки Flutter).
Такие тесты и правда удобно использовать для записи демо-видео или создания нужных скриншотов.