Мы — команда мобильного проекта FL.ru (Алина Смирнова, Mobile QA Engineer и Виктор Котолевский, Flutter Mobile Development Lead). В своей статье хотим познакомить вас с Flutter Driver и рассказать об автоматизации UI тестирования мобильных приложений с помощью данного инструмента. Лонгрид состоит из двух частей: про автоматизацию и про расширение взаимодействия Flutter Driver с приложением.
Начнем.
Наш cервис FL.ru является крупнейшей русскоязычной биржей фриланса. Как и все мы сталкиваемся в работе с определенными трудностями в тестировании и стараемся оперативно решать поставленные задачи. С масштабированием приложения FL.ru появилась необходимость написания автотестов на проекте. Давайте познакомимся с Flutter Driver.
Flutter Driver — это инструмент, специально разработанный для тестирования приложений, созданных на FlutterОн очень похож на такие проекты, как Selenium driver, Protractor и Google Espresso, может использоваться для тестирования различных элементов пользовательского интерфейса и помогает писать тесты для интеграционного тестирования на языке программирования Dart. Мы решили остановиться на Flutter Driver, поскольку он представляет собой набор полезных методов для тестирования работы с интерфейсом приложения прямо из «коробки».
Настройка окружения
Для того чтобы начать писать тесты, необходимо добавить в зависимости тестируемого проекта пакет flutter driver в файл pubspec.yaml:
flutter_driver:
sdk: flutter
test: any
Наборы интеграционных тестов не выполняются в том же процессе, что и тестируемое приложение, поэтому необходимо создать в проекте папку test driver с двумя файлами внутри: app.dart и app_test.dart.
Первый файл содержит в себе приложение, оснащенное инструментами для управления приложением через тестовые наборы, которые описываются во втором файле.
Наименование файла app.dart может быть любым. Файл app_test.dart должен соответствовать названию первого файла, но с окончанием _test. Таким образом, появится следующая структура:
my_app/
lib/
main.dart
test_driver/
app.dart
app_test.dart
Импортируем расширение flutter_driver в файл app.dart и активируем его работу в функции main:
import 'package:flutter_driver/driver_extension.dart';
import 'package:counter_app/main.dart' as app;
void main() {
enableFlutterDriverExtension();
app.main();
}
Теперь можно приступать к написанию тестов в файле app_test.dart. В соответствии с официальной документацией, необходимо импортировать библиотеки flutter_driver и test. Первая предоставляет API для тестирования flutter приложений и запуска на эмуляторах и реальных устройствах, а вторая стандартный способ написания и запуска тестов в Dart. Пример структуры тестового набора:
import 'package:flutter_driver/flutter_driver.dart';
import 'package:test/test.dart';
// Тесты можно сгруппировать с помощью функции group ()
void main() {
group(My App', () {
FlutterDriver driver;
// Перед запуском тестов вызываем функцию соединения с flutter driver
setUpAll(() async {
driver = await FlutterDriver.connect();
});
// Завершаем соединение с flutter driver после прогона тестов
tearDownAll(() async {
if (driver != null) {
driver.close();
}
});
// Описываем тест, в данном случае тест ищет текст, содержащий "0"
test('starts at 0', () async {
// Объявляем локаторы для поиска элементов в интерфейсе
final counterTextFinder = find.byValueKey('counter');
// Описываем действия с элементами интерфейса
expect(await driver.getText(counterTextFinder), "0");
});
});
}
Таким образом, создав локаторы определенных виджетов, мы можем написать тесты, которые стартуют после подключения к приложению через функцию setUpAll(), проходят необходимые сценарии и завершаются отключением от приложения через функцию tearDownAll().
Поиск и определение локаторов
Что такое локаторы? Локатор — это команда, которая сообщает Flutter Driver, какие элементы графического интерфейса (например, текстовое поле, кнопки, чекбоксы и т. д.) необходимо использовать для имитации работы пользователя (при нажатии, пролистывании, свайпе и т.д.).
Во Flutter есть несколько способов поиска и обозначения локаторов: bySemanticsLabel, byTooltip, byType, byValueKey.
Чтобы написать UI-тест, нам нужно знать, как создать «ссылку» на элемент пользовательского интерфейса для доступа к его свойствам. Если у элемента нет локатора, самый простой и надежный способ добиться этого — сделать «ссылку» на ключ (key) элемента пользовательского интерфейса и найти этот локатор методом findbyValueKey().
Ключ (key) — это идентификатор, который указывает на искомый элемент в структуре интерфейса. Допустим, нам требуется создать ключ для кнопки перехода в профиль пользователя. Соответственно, нужно убедиться, что у компонента пользовательского интерфейса (Flat Button) есть локатор или добавить его самостоятельно:
return FlatButton(
key: Key('profileButton'),
child: Text('Профиль')
)
Если неизвестно, где находится конкретный элемент интерфейса в коде, на помощь придут инструменты Android Studio: Flutter Inspector или DevTools. Для этого:
Запускаем приложение
Открываем Flutter Inspector или DevTools
Переходим в режим просмотра элементов интерфейса Select Widget Mode
После активации функции Select Widget Mode любое касание на элемент интерфейса позволит переместиться в коде проекта на искомый виджет.
Включить Flutter Inspector можно через View > Tool Windows или же задать настройку старта инструмента с запуском Android Studio через File > Settings > Languages and Frameworks > Open Flutter Inspector on launch.
Доступ к Flutter Inspector через меню
2. Включение Flutter Inspector при запуске Android Studio
3. Пример работы функции Select Widget Mode из Flutter Inspector
Через DevTools — специальный набор инструментов для отладки под Flutter, также доступен этот же функционал, за исключением того, что DevTools открываются в браузере.
4. Инструмент DevTools доступен из вкладки Run
5. Пример работы функции Select Widget Mode из DevTools
После того как элемент найден, необходимо создать «ссылку» на компонент пользовательского интерфейса в тестовом файле:
final userProfile = find.byValueKey('profileButton');
И описать действия в теле теста, которые необходимо выполнить с элементом, например, осуществить нажатие или определить текст на кнопке:
await driver.tap(userProfile);
expect(await driver.getText(userProfile), "Профиль");
Чтобы сделать тесты более читаемыми и удобными в обслуживании, их можно структурировать с использованием подхода DRY, то есть поместить все локаторы в одном месте или разбить их на классы по функционалу приложения. В случае, если UI с использованием какого-то элемента изменится, то мы изменим локатор лишь в одном месте, например, класс локаторов для страницы профиля:
abstract class ProfileIds {
static const profileAvatar = "profileAvatar";
static const buttonFeedback = "buttonFeedback";
static const editProfileButton = "editProfileButton";
static const portfolioButton = "portfolioButton";
}
Методы Flutter Driver
Основные методы взаимодействия с интерфейсом тестируемого приложения представляют собой следующий список:
tap() — воспроизводит тап по центру виджета;
screenshot() — создает снимок экрана;
enterText() — вводит текст в поле для ввода, на котором находится фокус;
getText() — возвращает текст из виджета;
scroll() — возвращает действие прокрутки экрана;
scrollIntoView() — прокручивает элемент со свойством Scrollable в котором расположен искомый элемент;
scrollUntilVisible() — повторяет прокрутку виджета со свойством Scrollable до тех пор, пока искомый элемент не появится на экране.
Запуск тестовых сценариев
flutter drive --target=test_driver/app.dart
Данная команда создает билд приложения и устанавливает его на устройство/эмулятор, запускает это приложение и прогон тестовых сценариев из из файла app_test.dart.
Для удобства можно разбить тесты на группы файлов по функционалу и запускать их по отдельности (или все с помощью первой команды если импортировать их в app_test.dart):
flutter drive --driver=test_driver/ui/login/login_test.dart --target=test_driver/ui/app.dart
Примеры работы методов Flutter Driver
Нажимаем на чекбокс, после того, как он появится на экране:
final checkbox = find.byValueKey('checkboxItem');
await driver.waitFor(checkbox ).then((_) => driver.tap(checkbox));
Пролистываем список заказов до первой публикации:
final feedList = find.byValueKey(FeedIds.newFeedList);
final feedItem = find.byValueKey(feedProjectCard + "1");
await driver.scrollUntilVisible(feedList, feedItem, dyScroll: -300);
Отвечаем в чате:
final fieldMessage = find.byValueKey(MessageIds.fieldMessage);
final sendMessage = find.byValueKey(MessageIds.buttonSendMessage);
await driver.tap(fieldMessage);
await driver.enterText("Здравствуйте");
await driver.waitFor(sendMessage).then((_) => driver.tap(sendMessage));
await driver.waitFor(find.text("Здравствуйте"));
Написание тестов пользовательского интерфейса имеет много преимуществ. Это один из самых надежных типов тестов в пирамиде тестирования, поскольку они наиболее близки к опыту конечного пользователя и дадут вам уверенность в том, что ваше приложение работает должным образом.
В заключении данной части хочу поделиться проблемами, с которыми мы столкнулись при автоматизации тестирования с Flutter Driver:
1. медленное прохождение тестов — для ускорения мы убрали авторизацию на каждом тесте;
2. плохо читаемая структура при увеличении количества тестов — для исправления этой ситуации мы разделили их по группам функционала;
3. зависимость тестов от работы back-end —- решаем с помощью использования моков в тестах.
Расширение взаимодействия Flutter Driver с приложением
Flutter Driver обладает достаточной функциональностью для взаимодействия с приложением на уровне интерфейса, а именно тапы, ввод текста, прокрутка, поиск элементов по ключу и т.д. Но порой требуется реализовать более сложные схемы взаимодействия для обеспечения необходимых условий прохождения тестов. Возможно, на экране есть виджет, состояние которого не удастся определить простым driver.getText. Или для прохождения теста нужно сымитировать ответ с сервера. Для этого существует метод enableFlutterDriverExtension, который необходимо вызвать перед запуском тестов в главном методе main.
Метод enableFlutterDriverExtension имеет три параметра: DataHandler handler, bool silenceErrors, List<FinderExtension> finders. Рассмотрим эти параметры более подробно.
Параметр DataHandler handler является функцией:
Future<String> Function(String message)
Она обеспечивает обработку сообщений, переданных через метод driver.requrestData. Ниже представлен пример использования этого метода.
void main() {
enableFlutterDriverExtension(handler: (string) => UITestMessageHandler.handle(string));
/// Запуск driver тестов
/// runApp();
}
abstract class UITestMessageHandler {
static final _messageHandlers = <UITestMessageHandler>[
GetStringMessageHandler(),
];
static Future<String> handle(String message) async {
try {
final json = jsonDecode(message);
final type = json['type'];
final data = json['data'];
return _messageHandlers.firstWhere((handler) => handler.canHandle(type), orElse: () => null)?.handleMessage(data) ?? null;
} catch (e) {
return null;
}
}
bool canHandle(String type);
Future<String> handleMessage(data);
}
class GetStringMessage extends UITestMessage {
static const String typeId = 'GetStringMessage';
GetStringMessage(String stringKey) : super(stringKey);
@override
String get type => typeId;
}
class GetStringMessageHandler extends UITestMessageHandler {
@override
bool canHandle(String type) => type == GetStringMessage.typeId;
@override
Future<String> handleMessage(data) async {
if (data != null && data is String) {
final s = await S.load(Locale('ru', ''));
switch (data) {
case StringKey.messageOk:
return s.message_ok;
case StringKey.messageCancel:
return s.message_cancel;
}
}
return null;
}
}
class StringKey {
static const messageOk = "messageOk";
static const messageCancel = "messageCancel";
}
В данной реализации все сообщения передаются в виде json объекта с обязательными полями type и data. Тип сообщения (type) помогает определить класс, который будет обрабатывать сообщения. В нашем случае сообщение GetStringMessage обрабатывает класс GetStringMessageHandler. Метод handleMessage будет вызван уже внутри приложения, тем самым мы можем получать локализованные строки по ключу из класса StringKey, достаточно внутри теста сделать вызов:
static Future<void> assertString(FlutterDriver driver, String stringKey, {String keyId}) async {
final stringValue = await driver.requestData(GetStringMessage(stringKey).toString());
expect(await driver.getText(find.text(stringValue), stringValue);
}
В следующем примере представлена реализация авторизации без необходимости обращения к серверу:
class AuthorizeMessageHandler extends UITestMessageHandler {
@override
bool canHandle(String type) => type == AuthorizeMessage.typeId;
@override
Future<String> handleMessage(data) async {
if (data != null && data is Map<String, dynamic>) {
final user = User.fromJson(data['user']);
return DiProvider.get<LoginService>()
.statuses.add(user).then((value) => 'ok');
}
return null;
}
}
class AuthorizeMessage extends UITestMessage {
static const String typeId = 'AuthorizeMessage';
AuthorizeMessage(User user) : super({
'user': user.toJson(),
});
@override
String get type => typeId;
}
Класс LoginService, предоставляющий API для авторизации, имеет поле PublishSubject<User> statuses. В простом случае, пользователь вводит логин/пароль, после чего эти данные передаются на сервер, где уже происходит авторизация. Далее сервер возвращает объект User, который уже добавляется в поток statuses. Теперь, с помощью AuthorizeMessageHandler, нам не требуется прохождение экранов ввода логина/пароля, для того, чтобы авторизоваться в приложении во время работы интеграционных тестов.
Далее мы рассмотрим параметр List<FinderExtension> finders. Этот список хранит пользовательские классы для поиска и валидации виджетов. Ниже приведен пример реализации FinderExtension для поиска виджета MyWidget по значению параметра param.
class MyWidget extends StatelessWidget {
final String param;
const MyWidget(this.param, {Key key}) : super(key: key);
@override
Widget build(BuildContext context) {
/// build MyWidget
}
}
class MyWidgetFinder extends SerializableFinder {
const MyWidgetFinder(this.param);
final String param;
@override
String get finderType => 'MyWidget';
@override
Map<String, String> serialize() => super.serialize()
..addAll({'param': param});
}
class MyWidgetFinderExtension extends FinderExtension {
String get finderType => 'MyWidget';
SerializableFinder deserialize(Map<String, String> params, DeserializeFinderFactory finderFactory) {
return MyWidgetFinder(params['param']);
}
Finder createFinder(SerializableFinder finder) {
MyWidgetFinder myWidgetFinder = finder as MyWidgetFinder;
return myWidgetFinder.byElementPredicate((Element element) {
final Widget widget = element.widget;
if (element.widget is MyWidget) {
return element.widget.param == myWidgetFinder.param;
}
return false;
});
}
Параметр silenceErrors говорит сам за себя: если значение true, то возникшие в ходе обработки сообщений исключения не будут логироваться.
Таким образом, Flutter Driver позволяет разработчикам и тестировщикам-автоматизаторам добавлять свои уникальные функциональности для организации сложных интеграционных тестов. В рамках нашего проекта FL.ru мы смогли легко подключить тесты к CI, что помогло нам ускорить процесс тестирования и повысить качество: теперь сбои обнаруживаются быстрее и, как таковые, могут быть быстрее устранены, что ведет к более продуктивному флоу работы.