Привет всем, кому нравятся Flutter и автотесты!
Когда мы в Surf начали разрабатывать на Flutter, стало интересно посмотреть, что же может автоматизировать сам Flutter? Ведь приложения, которые он создает, кроссплатформенные, а значит и автотесты будут такими... Однако стабильный пакет для работы с E2E и Widget-тестами включили во Flutter-фреймворк не так давно — в начале декабря 2020 года. Поэтому обсудить эту тему интересно.
Сегодня речь пойдет об автотестировании в рамках Flutter-фреймворка в контексте мобильных приложений.
Об особенности тестирования мобильных Flutter-приложений мы уже немного говорили в статье «Тестирование Flutter-приложений: инструменты, преимущества, проблемы».
Автотесты на Flutter
Flutter даёт возможность писать автотесты нативно на языке Dart. Во фреймворке есть:
Unit-тесты, их задача — проверить конкретный модуль системы. Например, убедиться, что контроллер компонента выставляет нужное состояние. Так как это не проверка интерфейса, эмулировать приложение не нужно.
Widget-тесты. Любое Flutter приложение состоит из компонентов Widget: Widget-тесты позволяют эмулировать Widget и проводить с ним необходимое тестирование. Это могут быть целые экраны и отдельные элементы: поля, кнопки, чекбоксы и так далее.
End-to-end (далее — E2E) — тесты, при которых мы полностью загружаем приложение и имитируем действия пользователя в реальной инфраструктуре с реальными сервисами, API и так далее.
Базово о тестах на Dart во Flutter читайте в статье «Тестирование Flutter-приложений: начало».
В Surf Unit-тесты пишут разработчики, потому что они ближе к приложению. Widget- и E2E- тесты пишут автоматизаторы тестирования: оба видов тестов тесно связаны с готовым приложением и проверками для тестирования.
Стратегия имплементации и использования автотестов во Flutter
Для полноценного тестирования нужны и Widget-, и E2E-тесты.
Widget-тесты. Хорошо подходят для запуска на пулл-реквестах. Работают на моковых данных и проходят в несколько раз быстрее.
E2E-тесты. Тестировать приложение на полных пользовательских сценариях и реальном сервере необходимо — иначе сложно утверждать о полной работоспособности системы. При этом запускать их, например, на пулл-реквестах нецелесообразно: E2E-тесты медленные и нестабильные. Их нестабильность — не в том, как они написаны, а в инфраструктуре: всегда может упасть сервер, отвалиться какой-то запрос или же на реальном девайсе появится системный диалог. Всё это уронит тест.
Автотесты приносят максимальную пользу, если запускать их на стабильных фичах как можно чаще.
Пример
Бизнес-сценарий по успешной и неуспешной авторизации покрываем несколькими негативными и позитивными проверками при помощи E2E-тестов. Чтобы проверить работоспособность отдельных модулей систем, нужны Widget-тесты. Минимум по одному на каждый экран, который посещает E2E-сценарий, а также по надобности на каждый Widget-элемент.
Новый пакет
В конце 2020-го во Flutter обновили пакет flutter_test. До этого Flutter работал с Widget- и E2E-тестами отдельно.
Для компонентов Widget был класс WidgetTester. Он позволял загружать Widget и проверять его. Для E2E — класс FlutterDriver. Он загружал приложение и позволял имитировать взаимодействие с интерфейсом. Теперь остался только WidgetTester: Flutter сделали driver-подход deprecated.
Первое, что бросается в глаза, — всё стало проще. E2E-тесты работают теперь через то же API, что и Widget-тесты, — внутри пакета flutter_test через WidgetTester. Поэтому реализовать поддержку Widget- и E2E-тестов одновременно стало проще.
Структура автотестов в Flutter-проекте Surf
.
├── build.yaml
├── dart_test.yaml
├── integration_test
│ ├── credentials
│ │ ├── profiles
│ │ │ └── *_profile.dart
│ │ ├── credentials.dart
│ │ └── texts.dart
│ ├── features
│ │ └── *.feature
│ ├── gherkin
│ │ └── reports
│ │ └── gherkin_reports.json
│ ├── hooks
│ │ └── *_hook.dart
│ ├── step_definitions
│ │ └── *_steps.dart
│ ├── worlds
│ │ └── *_world.dart
│ ├── gherkin_suite_test.dart
│ ├── gherkin_suite_test.g.dart
│ └── step_definitions_library.dart
├── lib
│ ├── ui
│ │ └── res
│ │ └── test_keys.dart
│ └── main.dart
├── test
│ ├── widget_test
│ └── tests
├── test_screens
│ ├── screens
│ │ └── *_screen.dart
│ ├── utils
│ │ └── *_utils.dart
│ ├── screens_library.dart
│ └── test_delays.dart
├── pubspec.yaml
└── test_driver
└── integration_test.dart
build.yaml — Содержит настройку для build_runner. Основное применение — добавить папки и файлы, которые генератор кода может видеть.
dart_test.yaml — Содержит конфигурацию для Unit- и Widget-тестов. Например, теги для фильтрации тестов.
_profile.dart — Содержит соответствие аккаунтов в сценариях к реальным аккаунтам. Позволяет проще менять аккаунты в сценариях, а также иметь разные профили, если это потребуется.
credentials.dart — Содержит данные от аккаунтов из профилей. Данные можно указывать разные — какие требуются для сценария.
texts.dart — Содержит различные тексты в приложении. Например, тексты снеков ошибок для будущего их использования в тестах. Если в приложении несколько языков, для каждого создаётся отдельный объект.
.feature файлы — Содержат сценарии на языке Gherkin. Каждый шаг этих сценариев имплементируем и выполняем в рамках теста.
Gherkin
Cценарии для E2E-тестов мы в Surf пишем на человекочитаемом языке Gherkin. Благодаря этому любой член команды поймёт, что проверяется и как, — даже если он вообще не знаком с автотестами.
Можно оценивать примерное тестовое покрытие, проводя аналогию с тест-кейсами. Сценарии для написания — брать из тест-кейсов. Если тест зафейлится, тестировщикам проще будет его проверить: они воспользуются обычным сценарием для воспроизведения.
Поэтому для нас было важно, чтобы Gherkin можно было использовать и в автотестах Flutter.
gherkin_reports — Отчёт.
_hook.dart — Hooks, которые использует flutter_gherkin во время выполнения сценариев. Бывают разных видов, выполняются в необходимый момент состояния приложения.
_steps.dart — Файлы, содержащие имплементацию шагов из Gherkin-сценария. Разбиты по разным файлам по смыслу. Вместе их собирает step_definitions_library.dart. В файле есть класс шагов со списком определений steps.
_world.dart — Объекты, которые в flutter_gherkin фреймворке хранят состояние текущего сценария. В _world.dart-файлах мы можем добавить или переопределить логику объектов. Например, дать возможность передавать информацию внутри сценария между шагами.
gherkin_suite_test.dart — Файл, в котором настраивается flutter_gherkin и запускаются тесты.
gherkin_suite_test.g.dart — Файл, который генерируется пакетом build_runner на основе сценариев в feature-файлах. Причина: на текущий момент для работы integration_test нужно, чтобы весь код был сгенерирован до запуска тестов. Содержимое редактировать не стоит.
step_definitions_library.dart — Файл нужен для объединения списков имплементаций шагов в один список, который передастся в stepDefinitions-параметр конфига.
test_keys — Файлы с ключами компонентов Widgets для идентификации.
main — Файл, позволяющий запускать приложение.
test — Папка с Unit- и Widget-тестами.
_screen.dart — Используется, чтобы хранить «локаторы» в одном месте и разделять их по экранам и фичам. А также для переиспользования между Widget- и Е2Е-тестами: синтаксис у файндеров одинаковый. Также в файле экрана могут находиться параметры для контекста или «жесты», которые используются в шагах со свайпами.
_utils.dart — Файлы, содержащие различные вынесенные методы для переиспользования в шагах E2E-тестов или Widget-тестах. В основном — расширения WidgetTester-класса. В таких utils почти всегда требуется инстанс WidgetTester, поэтому вместо передачи его как параметра можно написать расширение. Так будет более понятно и удобно.
screens_library.dart — Файл для группировки экранов. Делает более простой импорт в шагах и Widget-тестах.
test_delays.dart — Разные задержки для запросов и взаимодействий.
pubspec.yaml — Содержит зависимости для flutter-проекта.
integration_test.dart — Большая часть файла — код, нужный для правильной генерации отчёта в формате Cucumber-json. Самая важная часть — в функции main. Она задаёт путь для отчёта и возвращает драйвер для тестов.
Gherkin-сценарий
Существуют готовые фреймворки по работе с Gherkin. Мы выбирали между двумя пакетами: flutter_gherkin и ogurets_flutter. В итоге выбрали flutter_gherkin. Его наиболее активно поддерживали: было видно, что над ним работают. Спойлер: мы не ошиблись — используем его по сей день.
#language: ru
Функциональность: Авторизация
@auth
Сценарий: Авто: Авторизация с корректным ОТП
Когда Я запускаю приложение
И Я перехожу на таб Библиотека
И Я тапаю на кнопку Войти
И Я ввожу рандомный телефон
И Я тапаю на кнопку далее
И Я ввожу ОТП код "12345"
Тогда Я вижу таб Библиотека авторизанта
< . . . >
На новых E2E-тестах приходится генерировать код шагов перед запуском. Парсить Gherkin вживую теперь не выходит: после правок Gherkin-сценариев нужно выполнять codegen. Он генерирует в Dart-коде файл со сценариями. Это вызывает проблемы: например, все сценарии в отчёте попадают в одну фичу.
Возможно, скоро это решат: flutter_gherkin активно поддерживают и улучшают.
Имплементация E2E-сценария
class AuthSteps {
when<ContextualWorld>(
RegExp(r'Я тапаю на кнопку Войти'),
(context) async {
final tester = context.world.appDriver.rawDriver;
await tester.implicitTap(AuthScreen.loginBtn);
await tester.pumpAndSettle();
},
),
when1<String, ContextualWorld>(
RegExp(r'Я ввожу ОТП код {string}$'),
(code, context) async {
final tester = context.world.appDriver.rawDriver;
await tester.pumpUntilVisible(AuthScreen.otpCreateScreen);
await tester.enterOtp(code, AuthScreen.otpFieldNoError);
await tester.pumpUntilVisible(AuthScreen.otpRetryScreen);
},
),
< . . . >
Шаг содержит строку, или регулярное выражение, для соотнесения с шагом из Gherkin-файла и функцию, которую нужно выполнить в этом шаге.
Цифра после названия ключевого слова в функции обозначает, сколько будет аргументов в шаге. Например, when1. Их мы передаем вместе с контекстом для функции-тела шага.
Важное уточнение: допустим, есть несколько шагов, похожих друг на друга. Один шаг целиком содержит другой: «Я тапаю на кнопку» и «Я тапаю на кнопку дважды». Это вызывает проблему: определяется имплементация шага, который нашелся первым. В этом случае важно использовать регулярные выражения (как и в других кроссплатформенных средствах). RegExp с $ на конце — RegExp('Я жму на кнопку$') — помогает избежать конфликтов, и явно показывает где конец шага. Больше не возникает ситуация, когда матчер выбрал не то определение шага.
Имплементация Widget-сценария
testWidgets('Максимальная длина поля 11 символов', (WidgetTester tester) async {
// given
await tester.pumpWidget(GeneralWidgetInit.defaultWrapper(
const AuthLoginScreen(),
));
await tester.pumpAndSettle();
await tester.doEnterText(AuthScreen.phoneFld, randomNumber(12));
// then
final field = tester.widget<TextField>(AuthScreen.phoneFld);
expect(field.controller.text.length, 11);
});
< . . . >
}); // group('Авторизация по телефону')
}
< . . . >
Раньше, чтобы обратиться к элементу, нельзя было определить его по локатору, чтобы тот подходил и для Widget-теста и для E2E-теста. Теперь это доступно автоматически.
Удобно, что можно взять целое свойство Widget — например, длину — и сравнить с ожидаемым результатом. При проверке ограничения поля вводим текст больше 11 символов. На выходе проверяем, что ввелось только 11.
Переиспользование компонентов и методов
Переиспользуемые компоненты
Вынести переиспользуемые компоненты теперь совсем просто. Оба вида тестов используют один класс — WidgetTester. Поэтому нет никаких проблем с работой селекторов или общих функций. Выносим их отдельно, например, и не знаем горя.
Итого: переиспользуем между Widget- и E2E-тестами селекторы (Finder-класс), функции и жесты.
class AuthScreen {
// Экран ввода пин-кода при авторизации
static Finder pinScreen = find.byKey(AuthTestKeys.pinScreen);
// Экран задания пин-кода
static Finder pinCreateScreen = find.byKey(AuthTestKeys.pinCreateScreen);
// Элемент пин-клавиатуры при вводе пин-кода при авторизации
static Finder pinNumberLogin(String number) =>
find.descendant(of: pinScreen, matching: find.byKey(AuthTestKeys.pinKeyboardBtn(number)));
// Поле Логин на экране авторизации
static Finder loginField = find.byKey(AuthTestKeys.loginField);
< . . . >
}
Ключи/Keys
abstract class AuthTestKeys
{
/// ключ поля логина на экране авторизации
static const Key loginField = Key('fld_auth_login');
}
class AuthScreen {
/// поле Логин на экране авторизации
static Finder loginField = find.byKey(AuthTestKeys.loginField);
}
Все ключи удобнее хранить в папке приложения. Так разработчики и тестировщики всегда знают, для чего какой ключ нужен. Если ключ не тестовый, то, скорее всего, он для чего-то нужен.
Плюс:
Улучшается читаемость кода.
Автодополнение помогает при составлении локаторов.
Нет ошибок и опечаток в тексте ключей: они уникальные и ссылаются на переменную.
Классы экранов и ключей инициализировать не нужно: физического смысла в них нет.
Переиспользуемые методы
Переиспользуемые методы зеркалят экраны, но предоставляют методы для шагов, чтобы не дублировать код. Почти всегда в utils нужен доступ к WidgetTester, а передавать его как параметр функции не очень красиво, поэтому используем extension.
extension AuthExtendedWidgetTester on WidgetTester {
/// Метод для ввода пин-кода [pin]. Экран задается [pinNumber]
/// т.к. Finder кнопки зависит от экрана И цифры пина, поэтому Finder получаем в этом методе
Future<void> enterPin(String pin, Finder Function(String) pinNumber) async {
final streamPin = Stream.fromIterable(pin.split(''));
await pumpForDuration(const Duration(milliseconds: 500));
await for (final String ch in streamPin) {
await implicitTap(pinNumber(ch));
}
}
}
Вызов enterPin через tester. Так сразу понятно, что происходит.
Жесты
Offset scrollDown = Offset(0, -120)
abstract class GeneralGestures {
/// Направление для scroll - направление движения списка
/// Направление для flick/swipe etc - направление движения пальца
// жесты для скролла экранов вниз
static const Offset scrollDown = Offset(0, -120);
static const Offset flickUp = Offset(0, -600);
// жесты для скролла экранов вверх
static const Offset scrollUp = Offset(0, 120);
static const Offset flickDown = Offset(0, 600);
}
Часто в тесте нужно свайпать или скроллить. Писать каждый раз const Offset scrollDown = Offset(0, -120) неудобно. Если нужно поменять — громоздко. Поэтому есть файлы с жестами, которые вызываются в шагах.
Например, жесты для скролла экранов в общих жестах.
Функции
Мы дописали новые функции для работы с автотестами во Flutter, чтобы сделать работу более удобной и оптимальной.
Например,
pump
В Widget- и E2E-тестах между действиями нужно совершать pump, который постоянно помогает приложению крутиться дальше. Приходится самостоятельно указать тестовой среде, что требуется перестроить Widget. Не делаешь Pump — приложение стоит на месте.
По умолчанию во flutter_test есть:
pump() — Метод, который запускает обработку кадра после заданной задержки.
pumpAndSettle() — Ждёт завершения отрисовки новых кадров до таймаута. Этот метод будет крутиться без конца, если у анимации нет завершения.
Такие методы в целом пойдут, но встречаются ситуации, которые не работают для них. Поэтому мы создали несколько вспомогательных методов для удобства.
pumpUntil
/// Функция для pump пока не будет обнаружен Widget
Future<bool> pumpUntilVisible(Finder target,
{Duration timeout = _defaultPumpTimeout, bool doThrow = true}) async {
bool condition() => target.evaluate().isNotEmpty;
final found = await pumpUntilCondition(condition, timeout: timeout);
// ignore: only_throw_errors
if (!found && doThrow) throw TestFailure('Target was not found ${target.toString()}');
return found;
}
/// Метод для того, чтобы делать pump, пока не произойдет условие [condition]
Future<bool> pumpUntilCondition(bool Function() condition,
{Duration timeout = _defaultPumpTimeout}) async {
final times = (timeout.inMicroseconds / _minimalPumpDelay.inMicroseconds).ceil();
for (var i = 0; i < times; i++) {
if (condition()) {
await pumpForDuration(_minimalInteractionDelay);
return true;
}
await pump(_minimalPumpDelay);
}
return false;
}
Такой метод делает pump, пока не произойдет определенное условие или таймаут.
Вы могли заметить _minimalPumpDelay переменную. Однажды мы столкнулись с ситуацией, когда тесты начинают видеть кнопку с другого экрана, хотя ещё не завершилась анимация перехода на этот экран. Если делать pump() в цикле с нахождением Widget, это может забить весь поток, и приложение начнёт тупить. Тесты проходят долго и иногда флакуют. Логика не успевает отработать, потому что заблочена сложными локаторами.
Решение: добавили задержку 50 мс на pump в циклах. Всё наладилось. Поэтому _mininalPumpDelay нужен, чтобы анимации успевали завершаться после нахождения Widget.
implicitTap
Future<void> implicitTap(Finder finder, {Duration duration}) async {
final found = await pumpUntilVisible(finder, duration: duration ?? _defaultPumpTimeout);
if (!found) {
// ignore: only_throw_errors
throw TestFailure(finder.toString());
Метод implicitTap работает с использованием удобных pumpUntil* методов. implicitTap — это «неявный» тап. Он будет ждать через метод pumpUntilVisible свой Finder и тапать на него. Если таймаут — выкидывает ошибку теста.
Context
class ContextualWorld extends FlutterWidgetTesterWorld {
Map<String, dynamic> scenarioContext = <String, dynamic>{};
@override
void dispose() {
super.dispose();
scenarioContext.clear();
}
T getContext<T>(String key) {
return scenarioContext[key] as T;
}
void setContext(String key, dynamic value) {
scenarioContext[key] = value;
}
Future<void> attachScreenshot() async {
final bytes = await appDriver.screenshot();
attach(base64Encode(bytes), 'image/png');
}
}
В некоторых сценариях требуется запоминать данные. Например: открыли деталку карты, скрыли карту и хотим найти её в скрытых. Для этого нужно запомнить id продукта — то есть данные для поиска.
Хотелось хранить любые параметры, поэтому создали собственный World — ContextualWorld. Для удобства есть два метода-хелпера:
setContext задаёт значение для ключа в контекст.
getContext возвращает значение. Важно getContext сделать с дженериком, чтобы не писать каждый раз в шагах, например, as String. Контекст может быть динамический, поэтому нужно кастить типы. С помощью метода можно просто указать в <T> тип <String>.
Параметры для переиспользования хранятся в файлах экранов. Это позволяет пользоваться читаемыми параметрами вроде AuthParams.user и не писать каждый раз строки ключей.
Таким образом мы переписали авторизацию в сценариях. Есть шаг «Допустим Я использую аккаунт "account"», он запоминает user. Все шаги, которые делают действия с авторизацией, используют этот параметр из контекста. В сценариях не нужно писать ввожу пароль юзера "user", ввожу ОТП юзера "user" и тому подобное.
Hooks
Hooks (далее - Хуки) — перехватчики для простого управления жизненным циклом компонентов Widget.
Сброс состояния приложения
В некоторых больших проектах используют GetIt — глобальное хранилище состояний. Оно инициализируется один раз при запуске. Но тексты могут запускать приложение несколько раз за сессию, это приводит к конфликтам. Тип хука onBeforeScenario сбрасывает GetIt, и запуск приложения не падает.
onAfterScenarioWorldCreated сбрасывает все хранилища приложения перед новым сценарием, потому что сценарий может упасть, и тогда хук не выполнится.
Скриншот при падении теста
Есть хук, который делает скриншот при падении теста. Это удобно, когда выполняешь весь прогон и смотришь отчёт.
Перезапуск
when<ContextualWorld>(
RegExp(r'Я перезапускаю приложение$'),
(context) async {
final navigator = devGetIt<GlobalKey<NavigatorState>>().currentState;
unawaited(navigator.pushAndRemoveUntil(SplashScreenRoute(), (_) => false));
final tester = context.world.appDriver.rawDriver;
await tester.pumpUntilVisibleAny([AuthScreen.loginField, AuthScreen.pinScreen]);
},
),
Ещё одна проблема — необходимость перезапускать приложение во время сценария. Например, при установке пин-кода и авторизации по нему.
Решили её весьма элегантно: получаем текущий навигатор, возвращаем его до Route-сплеш-экрана — это самый первый экран. Не буквальный перезапуск, но происходит быстрее, и приложение работает как с перезапуском.
Steps
Фреймворк принимает шаги списком сущностей StepDefinitionGeneric: нужно либо писать все шаги в одном списке, либо называть их каждый раздельно, потом объединять в список и так далее.
Мы делим шаги на экраны. В каждом файле шагов есть класс с параметром steps: в него кладем шаги. Если шаги однотипные, например, «Я вижу деталку дебетовой карты» и «Я вижу деталку кредитной карты», выносим их в отдельную приватную переменную и потом мержим в список. Потом все шаги всех экранов мержатся в один список в файле step_definitions_library.dart и импортируются в конфиге.
static Iterable<StepDefinitionGeneric> get steps => [
..._SeeProductDetails.steps,
<otherStepsHere>,
]
---
/// Шаги "Я вижу" детальных экранов продуктов
class _SeeProductDetails {
static final Iterable<StepDefinitionGeneric> steps = [
then<ContextualWorld>(
RegExp(r'Я вижу ...
Покрытие
Измерять тестовое покрытие можно:
Widget-элементами кода. В этом помогут сервисы типа Сoverage.
По аналогии с ручными кейсами: компонентными и сценарными.
Отчёты
Для отчётов используем Cucumber-html-reporter. Фреймворк завершает прогон и создаёт json-файл в формате Сucumber. С помощью стороннего приложения можно получить интерактивный html-отчёт: его удобно смотреть и можно использовать как артефакт. Достаточно положить файл в папку с этим Node-приложением и запустить: сгенерируется файл.
Проверки
Любой тест начинается со сценария. Без понимания, как именно мы пишем сценарии, слабо понятен весь профит. Зачем используем оба вида тестов? Почему оба типа автотестов пишут QA, а не разработчики?
У нас есть два вида проверок:
бизнес-сценарии, которыми покрываем критические пути приложения;
компонентные, которыми проверяем работоспособность каждого элемента.
Бизнес-сценарии
Пример бизнес-сценария
Запустить приложение -> Оказываемся на экране Авторизации -> Аккаунт имеется, Верно ввести логин и пароль -> Авторизоваться успешно -> Попадаем на экран каталога -> Открыть детали любой книги -> Добавить книгу в корзину -> Значок “в корзину” меняется на кнопку “перейти в корзину” -> Перейти в корзину -> В корзине лежит добавленная книга -> Перейти к оформлению заказа -> Выбрать оплату при получении, заполнить данные по адресу -> Оформить успешно -> Заказ создан, книга из корзины пропала, но имеется запись на экране Мои заказы
Из бизнес-сценариев получаем возможность качественно осуществлять регрессивное тестирование и проверить приложение в тех местах, которые часто использует пользователь.
Пример
Есть приложения книжного магазина с возможностью заказа в авторизованной зоне. Для него такими сценариями будут:
-> авторизоваться успешно и неуспешно (например, ввести неверный пароль),
-> выбрать книгу (из поиска или каталога),
-> дойти до корзины и оформить заказ с оплатой.
Плюс различные комбинации, отражающие критические пути приложения.
Компонентные проверки
Берём каждый экран фичи и элемент экрана, работаем с ним как с самостоятельным. Полностью покрываем проверками: например, если имеем дело с полем, должны проверить его отображение, ограничение на вводимые символы, валидацию и так далее.
Коротко коснемся того, как разбивать фичу на детальные компоненты.
Мобильные приложения состоят из экранов, шторок, попапов, полей ввода, кнопок, чек-боксов, свитчеров и так далее… Именно от этого мы отталкивались изначально: решили разбить все составляющие на элементы и места, где они находятся. Далее — углубиться в клиент-серверную архитектуру приложения (без неё сегодня никуда).
Данные на экране почти всегда берутся из запроса. Он может отдать корректный и некорректный ответы. Стоит проверять экран отдельно и в прямом взаимодействии с запросом. Аналогично работает и с элементом.
Статья о том, как именно мы разбиваем фичу на компонентные проверки и что в них входит.
Каждый элемент во Flutter — это Widget, поэтому тестирование Widget-элементов мапится на компонентные тесты, которые детально покрывают каждый элемент или экран. Целые пользовательские сценарии отлично мапятся на E2E-тесты.
Думаю, теперь явно понятно, почему оба вида тестов удобнее писать именно QA-отделу: все сценарии нужны для ручных активностей. Widget- и E2E-тесты способны облегчают работу ручного тестирования, например, при регрессе или полном full-тестировании.
Плюсы нашей стратегии
Стабильные быстрые Widget-тесты. Это позволяет запускать их на pull-requests и предотвращает попадание багов в сборки для тестирования. Их легко поддерживать: всегда видно, если при изменении кода в мобильном приложении тест сломался.
E2E-тесты покрывают пользовательские сценарии. Их можно запускать по ночам и облегчить жизнь ручного тестировщика, покрыв санити- или смоук-прогоны.
Компонентное и интеграционное тестирование. Два вида тестов помогают работать с моковыми данными и с реальным сервером в одном проекте. Такой подход позволяет измерять покрытие кода, сценариев и ТЗ.
Плюсы и минусы такого подхода к автотестам
Плюсы
Тесты одновременно нативные и кроссплатформенные. Активная поддержка фреймворка.
Доступ к архитектуре и сущностям позволяет проставлять локаторы для нужных элементов самостоятельно, никого не привлекая.
Изменения файлов в мобильном приложении всегда видны автотестеру. Разработчики тоже видят компонентные и сценарные тесты и могут вовремя исправить ошибку у себя.
Минусы
Новый фреймворк обновляется по своим часам и не всегда синхронизируется с нативными инструментами. По факту этого минуса нет только у нативных инструментов для автотестирования. А вот у Calabash или Appium он есть.
Хочется отметить, что Flutter-коммьюнити без проблем идет на контакт. Они быстро отвечают и исправляют проблемы.
Тесты находятся внутри мобильного приложения и полностью зависят от него. Сломается приложение — сломаются тесты: например, если версия пакета обновилась и зависимости сломались.
Изменения в коде вынуждают постоянно делать билд (для E2E).
Костыли
Если упал один тест, упадут все сразу. Неудобно, раздражает, приходится писать свою логику, которая явно костыльная.
С прокси во Flutter вообще всё очень интересно. У Dart свой клиент для работы с сетью. Все запросы идут через него, поэтому настройки для работы с прокси нужно вводить внутри приложения. Для этого приходится писать дополнительную логику. Это важно, когда работа с проектами необходима под VPN. На одном из проектов мы подключаем компьютер к VPN, а устройство уже работает по прокси.
Подробнее — в статье «Тестирование Flutter-приложений: инструменты, преимущества, проблемы».
GetIt инициализируется один раз при запуске, и с ним возникают конфликты. В тестах приложение может «запускаться» несколько раз. Поэтому надо сбрасывать GetIt, чтобы оно иницилизировалось заново.
Нативные алерты не поддерживаются. Flutter-тесты не могут взаимодействовать с нативными алертами, поэтому для обхода можем просто при запуске тестов дать определенные разрешения через adb или simctl в файле integration_test.dart. Ишью об этом в репозитории Flutter есть, ждем…
Future<void> main() async {
integration_test_driver.testOutputsDirectory = 'integration_test/gherkin/reports';
/// Выдаем права на геолокацию чтобы не было проблем
await Process.run('xcrun', ['simctl', 'privacy', 'booted', 'grant', 'location-always', <app_packet>]);
[
'ACCESS_COARSE_LOCATION',
'ACCESS_FINE_LOCATION',
].forEach((permission) async => Process.run('adb',
['shell', 'pm', 'grant', <app_packet>, 'android.permission.$permission']));
return integration_test_driver.integrationDriver(
timeout: const Duration(minutes: 120),
responseDataCallback: writeGherkinReports,
);
}
Сравнение с существующими технологиями
Во всех фреймворках для реализации автотестов для Flutter есть плюсы и минусы
Мы не успели поработать на Calabash для автотестирования Flutter-приложений, хотя некоторые мысли на этот счёт есть. Теоретически работа возможна, но могут возникнуть проблемы с определением id элемента.
Если вы не выбираете нативные автотесты, стоит определиться, нужны ли, помимо Е2Е, Widget-тесты, и если да, то кто будет их писать.
Во Flutter есть крутой плюс, от которого сносит башню: это возможность обращаться к Widget-элементу когда угодно и брать любую информацию из него.
Субъективно рекомендую автотесты на Flutter, если:
прельщает близкая интеграция с приложением,
не пугает Dart,
хочется изучать новую технологию,
нравится возможность что-то творить самостоятельно,
интересно контрибьютить во Flutter и помогать разработке, а не только заниматься тестированием,
держать всё под своим контролем в рамках бОльшего покрытия.
Мы подготовили проект с базовым набором тестов по текущей архитектуре. По ссылке вы можете склонить проект и попробовать вживую то, что обсуждали в статье:
Спасибо коллеге по QA-отделу Владиславу Воронину за соавторство и подготовку тестового проекта.