Думаю, многие уже знакомы с 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
.