Про Flutter вспоминают тогда, когда нужно быстро сделать красивое и отзывчивое приложение сразу для нескольких платформ, но как гарантировать качество «быстрого» кода?
Вы удивитесь, но во Flutter есть средства для того, чтобы не только обеспечить качество кода, но и гарантировать работоспособность визуального интерфейса.
В статье рассмотрим, как обстоят дела с тестами на Flutter, разберем виджет-тесты и интеграционное тестирование приложения в целом.
Я начал изучать Flutter больше года назад, еще до его официального релиза, во время изучения не было проблемой найти какую-либо информацию по разработке. А когда захотелось попробовать TDD, выяснилось, что информации о тестировании катастрофически мало. На русском языке, так и вообще, практически нет. Вопросы тестирования пришлось изучать самостоятельно, по исходникам тестов Flutter и редким статьям на английском языке. Все что изучил по тестированию визуальных элементов, я описал в статье, чтобы помочь тем, кто только начинает вникать в тему.
Widget Testing (Виджет-тесты)
Общие сведения
Виджет-тест (widget test) тестирует один виджет. Его еще можно назвать компонентным тестом (component test). Целью теста является доказательство того, что пользовательский интерфейс виджета выглядит и взаимодействует так, как это запланировано. Тестирование виджета требует тестовую среду, которая обеспечивает соответствующий контекст жизненного цикла виджета.
Тестируемый виджет имеет возможность получать действия и события пользователя, и реагировать на них, строить дерево дочерних виджетов. Поэтому виджет-тесты является более сложным, чем unit-тесты. Однако, как и unit-тест, среда тестирования виджетов является простой имитацией, намного более простой, чем полноценная система пользовательского интерфейса.
Виджет-тестирование позволяет изолировать и проверить поведение отдельно взятого элемента визуального интерфейса. И, что примечательно, осуществить все проверки в консоли, что идеально для тестов, которые запускаются как этап CI/CD процесса.
Файлы, которые содержат тесты, принято располагать в подкаталоге test проекта.
Тесты можно запустить либо из IDE, либо из консоли командой:
$ flutter test
В этом случае выполнятся все тесты с маской *_test.dart из подкаталога test.
Можно запустить отдельный тест, указав имя файла:
$ flutter test test/phone_screen_test.dart
Тест создается функцией testWidgets, которая в качестве параметра tester получает инструмент, с помощью которого код теста взаимодействует с тестируемым виджетом:
testWidgets('Название теста', (WidgetTester tester) async {
// Код теста
});
Для объединения тестов в логические блоки, тестовые функции можно объединять в группы, внутри функции group:
group('Название группы тестов', (){
testWidgets('Название теста', (WidgetTester tester) async {
// Код теста
});
testWidgets('Название теста', (WidgetTester tester) async {
// Код теста
});
});
Функции setUp и tearDown позволяют выполнить некоторый код «до» и «после» каждого теста. Соответственно, функции setUpAll и tearDownAll позволяют выполнить код «до» и «после» всех тестов, а если эти функции вызванный внутри группы, то они будут вызваны «до» и «после» выполнения всех тестов группы:
setUp(() {
// код инициализации теста
});
tearDown(() {
// код финализации теста
});
Поиск виджетов
Для того, чтобы выполнить какие-то действия над вложенным виджетом, его нужно найти в дереве виджетов. Для этого есть глобальный объект find, который позволяет найти виджеты:
- в дереве по тексту — find.text, find.widgetWithText;
- по ключу — find.byKey;
- по иконке — find.byIcon, find.widgetWithIcon;
- по типу — find.byType;
- по положению в дереве — find.descendant и find.ancestor;
- с помощью функции, которая анализирует виджеты по списку — find.byWidgetPredicate.
Взаимодействие с тестируемым виджетом
Класс WidgetTester предоставляет функции для создания тестируемого виджета, ожидания смены его состояний и для выполнения некоторых действий над этими виджетами.
Любое изменение виджета вызывает изменение его состояния. Но тестовая среда не перестраивает виджет при этом. Нужно самостоятельно указать тестовой среде, что требуется перестроить виджет, путем вызова функций pump или pumpAndSettle.
- pumpWidget — создание тестируемого виджета;
- pump — запускает обработку смены состояния виджета и ожидает ее завершения в течении заданного таймаута (100 мс по умолчанию);
- pumpAndSettle — вызывает pump в цикле для смены состояний в течении заданного таймаута (100 мс по умолчанию), это ожидание завершения всех анимаций;
- tap — отправить виджету нажатие;
- longPress — длинное нажатие;
- fling — смахивание/свайп;
- drag — перенос;
- enterText — ввод текста.
Тесты могут реализовывать как позитивные сценарии, проверяя запланированные возможности, так и негативные, чтобы убедиться, что они не приводят к фатальным последствиям, например, когда пользователь нажимает не туда, куда надо, и вводит не то, что требуется:
await tester.enterText(find.byKey(Key('phoneField')), 'bla-bla-bla');
После любых действий с виджетами нужно вызывать tester.pumpAndSettle() для смены состояний.
Моки
Многие знакомы с библиотекой Mockito. Эта библиотека из мира Java оказалась столь удачной, что есть реализации этой библиотеки на многих языках программирования, в том числе и на Dart.
Для подключения необходимо добавить зависимость в проект. Добавляем следующие строки в файл pubspec.yaml:
dependencies:
mockito: any
И подключить в файле с тестами:
import 'package:mockito/mockito.dart';
Эта библиотека позволяет создать моковые классы, от которых зависит тестируемый виджет, для того, чтобы тест был более простым и охватывал только тот код, который мы тестируем.
Например, если мы тестируем виджет PhoneInputScreen, который при нажатии, используя AuthInteractor сервис, выполняет запрос к бэкенду authInteractor.checkAccess(), то подставив мок вместо сервиса, мы сможем проверить самое главное — наличие факта обращения к этому сервису.
Моки зависимостей создаются как наследники класса Mock и реализуют интерфейс зависимости:
class AuthInteractorMock extends Mock implements AuthInteractor {}
Класс в Dart является одновременно и интерфейсом, поэтому нет необходимости декларировать интерфейс отдельно, как в некоторых других языках программирования.
Для определения функциональности мока используется функция when, которая позволяет определить ответ мока на вызов той или иной функции:
when(
authInteractor.checkAccess(any),
).thenAnswer((_) => Future.value(true));
Моки могут возвращать ошибки или ошибочные данные:
when(
authInteractor.checkAccess(any),
).thenAnswer((_) => Future.error(UnknownHttpStatusCode(null)));
Проверки
В процессе выполнения теста можно проверить наличие виджетов на экране. Это позволяет убедиться в том, что новое состояние экрана корректно с точки зрения видимости нужных виджетов:
expect(find.text('Номер телефона'), findsOneWidget);
expect(find.text('Код из СМС'), findsNothing);
После выполнения теста, также можно проверить какие методы мокового класса вызывались в ходе теста, и сколько раз. Это необходимо, например, для понимания того, не слишком ли часто запрашиваются те или иные данные, нет ли лишних изменений состояния приложения:
verify(appComponent.authInteractor).called(1);
verify(authInteractor.checkAccess(any)).called(1);
verifyNever(appComponent.profileInteractor);
Отладка
Тесты выполняются в консоли без какой-либо графики. Можно запускать тесты в debug режиме и ставить точки останова в коде виджета.
Чтобы получить представление о том, что происходит в дереве виджетов, можно использовать функцию debugDumpApp(), которая, будучи вызвана в коде теста, выводит в консоль текстовое представление иерархии всего дерева виджетов в данный момент времени.
Чтобы понять, как виджет использует моки есть функция logInvocations(). Она в качестве параметра принимает список моков и выдает в консоль последовательность вызовов методов у этих моков, которые осуществлялись в тесте.
Пример такого вывода ниже. Отметка VERIFIED есть у вызовов, которые проверялись в тесте с помощью функции verify:
AppComponentMock.sessionChangedInteractor
[VERIFIED] AppComponentMock.authInteractor
[VERIFIED] AuthInteractorMock.checkAccess(71111111111)
Подготовка
Все зависимости должны подаваться в тестируемый виджет в виде мока:
class SomeComponentMock extends Mock implements SomeComponent {}
class AuthInteractorMock extends Mock implements AuthInteractor {}
Передача зависимостей в тестируемый компонент должна осуществляться каким-либо образом, принятым в вашем приложении. Для простоты повествования рассмотрим пример, когда зависимости передаются через конструктор.
В примере кода PhoneInputScreen — тестируемый виджет, на основе StatefulWidget, завернутый в Scaffold. Он создается в тестовом окружении с помощью функции pumpWidget():
await tester.pumpWidget(PhoneInputScreen(mock));
Однако реальный виджет может использовать выравнивание для вложенных виджетов, что требует наличия MediaQuery в дереве виджетов, возможно получает Navigator.of(context) для навигации, поэтому практичнее заворачивать тестируемый виджет в MaterialApp или CupertinoApp:
await tester.pumpWidget(
MaterialApp(
home: PhoneInputScreen(mock),
),
);
После создания тестового виджета и после любых действий с ним нужно вызывать tester.pumpAndSettle() для того, чтобы тестовое окружение обработало все смены состояний виджета.
Integration tests (Интеграционные тесты)
Общие сведения
В отличии от виджет-тестов, интеграционный тест проверяет полностью все приложение или какую-то большую его часть. Цель интеграционного теста заключается в том, чтобы убедиться, что все виджеты и сервисы работают вместе так, как и ожидалось. Процесс работы интеграционного теста можно наблюдать в симуляторе или на экране устройства. Этот метод хорошо заменяет ручное тестирование. Кроме того, можно использовать интеграционные тесты для проверки производительности приложения.
Интеграционный тест, как правило, выполняется на реальном устройстве или эмуляторе, таком как iOS Simulator или Android Emulator.
Файлы, содержащие интеграционные тесты, принято располагать в подкаталоге test_driver проекта.
Приложение изолировано от кода тестового драйвера и запускается после него. Тестовый драйвер позволяет управлять приложением во время теста. Выглядит это следующим образом:
import 'package:flutter_driver/driver_extension.dart';
import 'package:app_package_name/main.dart' as app;
void main() {
enableFlutterDriverExtension();
app.main();
}
Запуск тестов осуществляется из командной строки. Если запуск целевого приложения описан в файле app.dart, а тестовый сценарий называется app_test.dart, то достаточно следующей команды:
$ flutter drive --target=test_driver/app.dart
Если тестовый сценарий имеет другое имя, тогда его нужно указать явно:
$ flutter drive --target=test_driver/app.dart --driver=test_driver/home_test.dart
Тест создается функцией test, и группируются функцией group.
group('park-flutter app', () {
// драйвер, через который мы подключаемся к устройству
FlutterDriver driver;
// создаем подключение к драйверу
setUpAll(() async {
driver = await FlutterDriver.connect();
});
// закрываем подключение к драйверу
tearDownAll(() async {
if (driver != null) {
driver.close();
}
});
test('Имя теста', () async {
// код теста
});
test('Другой тест', () async {
// код теста
});
}
В этом примере показан код создания тестового драйвера, через который тесты взаимодействуют с тестируемым приложением.
Взаимодействие с тестируемым приложением
Инструмент FlutterDriver взаимодействует с тестируемым приложением через следующие методы:
- tap — отправить нажатие виджету;
- waitFor — ждать появление виджета на экране;
- waitForAbsent — ждать исчезновение виджета;
- scroll и scrollIntoView, scrollUntilVisible — прокрутить экран на заданное смещение или к требуемому виджету;
- enterText, getText — ввести текст или взять текст виджета;
- screenshot — получить скриншот экрана;
- requestData — более сложное взаимодействие через вызов функции внутри тестируемого приложения.
Возможна ситуация, когда потребуется оказать влияние на глобальное состояние приложения из кода теста. Например, для упрощения интеграционного теста путем замены части сервисов внутри приложения на моки. В приложении можно задать обработчик запросов, к которому можно обращаться через вызов driver.requestData('some param') в коде теста:
void main() {
Future<String> dataHandler(String msg) async {
if (msg == "some param") {
// какая то обработка вызова в приложении
return 'some result';
}
}
enableFlutterDriverExtension(handler: dataHandler);
app.main();
}
Поиск виджетов
Поиск виджетов при интеграционном тестировании при помощи глобального объекта find отличается составом методов от аналогичной функциональности при тестировании виджетов. Однако общий смысл практически не меняется:
- в дереве по тексту — find.text, find.widgetWithText;
- по ключу — find.byValueKey;
- по типу — find.byType;
- по подсказке — find.byTooltip;
- по семантической метке — find.bySemanticsLabel;
- по положению в дереве find.descendant и find.ancestor.
Заключение
Мы рассмотрели способы организации тестирования интерфейса приложения, написанного с использованием Flutter. Мы можем как реализовать тесты для проверки соответствия кода требованиям технического задания, так и делать тесты этим самым заданием. Из замеченных недостатков интеграционного тестирования — нет возможности взаимодействовать с системными диалогами платформы. Но, например, можно избежать запросов пермишенов путем выдачи разрешений из командной строки на этапе установки приложения, как описано в этом тикете.
Эта статья — отправная точка для изучения темы тестирования, которая в общих чертах знакомит читателя с тем, как функционирует тестирование пользовательского интерфейса. Она не избавит от чтения документации, из которой достаточно легко узнать как функционирует тот или иной класс или метод. Ведь изучения новой для себя темы требуется, в первую очередь, понимание всех происходящих процессов в целом, без излишней детализации.
Это не единственная наша статья про Flutter — например, здесь можно прочитать про Dart, откуда он взялся, и чем отличается от Java и Kotlin.