Flutter - это открытый и бесплатный фреймворк для разработки мобильных приложений от Google, который предлагает набор функций для создания кросс-платформенных приложений. Однако, как и любой другой инструмент, он требует эффективного использования практик тестирования для гарантии его качества. С помощью стандартной библиотеки Flutter, разработчики могут создавать различные типы тестов: unit-тесты, widget-тесты и интеграционные тесты. Каждый из этих типов тестов имеет свое специфическое применение и важность. Рассмотрим интеграционные тесты. Они проверяют, как взаимодействуют несколько частей приложения вместе, или даже весь стек приложения в целом. Так как фреймворк уже содержит в себе инструменты для написания таких тестов, Flutter-разработчики могут писать тесты, не используя дополнительных внешних инструментов. Если же тесты пишет отдельная QA-команда, это может стать проблемой, так как Dart - далеко не самый популярный язык. К счастью, есть инструменты, которые позволяют тестировать Flutter-приложения не используя Dart. Appium - это открытый инструмент автоматизации тестирования, который позволяет вам тестировать все типы приложений: нативные, гибридные, а также мобильные веб-приложения, в том числе и Flutter, используя популярные языки программирования. Appium состоит из трех основных компонентов или модулей: Сервер, который устанавливается на машине разработчика и слушает команды тестирования от клиента. Клиентские библиотеки для различных языков программирования (Java, Ruby, Python, PHP, JavaScript), которые обеспечивают поддержку для написания скриптов. Для Flutter добавляется третий элемент - библиотека Appium-Flutter-Driver, которая позволяет тестовым сценариям взаимодействовать с Flutter-приложением. В этом гайде описывается, как настроить окружение для тестирования с помощью Appium и создать тесты для Flutter-приложения используя Python. Предположим, что у нас уже есть какое-то приложение для тестирование. В этом гайде я буду использовать маленькое тестовое приложение, которое может следующее: Добавлять новые записи в список с изображением из галереи Удалять добавленные записи из спиcка Выполнять поиск по названию записи в Википедии, открывая страницу в WebView. Такой набор функциональности позволит показать написание тестов не только для самого Flutter, но и для нативных и WebView-компонентов, т.к. они часто присутствуют во Flutter-приложениях. Весь путь этого гайда можно повторить самому - весь код хранится в репозитории appium_flutter_demo, ветка - start. Для начала нам понадобится установленные Node и Python3, а также XCode и Android Studio. Если ваша платформа не MacOS, можете просто пропускать все части, связанные с iOS и Маком. Тестирование под Android остается идентичным. Подготовим инструменты, которые будут выполнять тестирование приложения, а именно сам Appium и Flutter Appium Driver. Можно установить последнюю версию (appium@next), но лучше установить конкретную, которая точно корректно работает с flutter-appium-driver (appium@2.0.0-beta.40). Устанавливаем Appium Flutter Driver. Для того, чтобы Flutter-приложением можно было управлять с помощью Flutter Appium Driver, добавим в проект пакет flutter_driver. Необходимо включить расширение flutter_driver при запуске приложения. Включение расширения влияет на поведение приложения, так, например, хардверная клавиатура не будет работать, потому что её заменяет виртуальная клавиатура flutter_driver. Чтобы избежать нежелательного поведения при обычной работе с приложением в debug/profile режиме, добавим отдельную точку входа в программу. Создадим файл main_test.dart, в нём добавим активацию расширения flutter_driver и непосредственный запуск нашего приложения. Соберем тестовые билды. flutter_driver может работать только если приложение собрано в debug или profile режиме. Тесты у меня будут прогоняться на симуляторе iOS и эмуляторе Android. Если сборка под Android не отличается между эмулятором и реальным устройством, то у iOS всё иначе. Profile-режим недоступен для симулятора, также нужно указывать, что сборка выполняется именно под симулятор. Записываем пути к созданным файлам, они еще понадобятся. Теперь можно заняться созданием проекта с тестами. Я буду использовать Python, но Appium имеет поддержку и других ЯП. API пакетов для разных языков отличается, поэтому при необходимости писать тесты, например, на Java или JS, сверяйтесь с документацией. Для работы с проектом установим Poetry. Вы можете использовать любой другой инструмент для управления зависимостями или вообще устанавливать пакеты через pip,  их здесь немного, мне просто нравится удобство Poetry. Создаём проект Добавляем в проект зависимости для работы с Appium и Flutter Driver. Кроме того, добавим пакет dotenv, чтобы далее можно было удобно указывать конфигурацию для тестов на конкретной машине. Создадим файл .env в корне проекта и добавляем в него следующие значения: Так как приложение работает и на iOS, и на Android, сделаем тесты кроссплатформенными. Платформу выполнения тестов определит переменная окружения “PLATFORM”. Создадим в проекте enum текущей платформы: Теперь можно настроить работу Appium. Для конфигурации подключения и поведения Appium использует такое понятие, как capabilities. По своей сути это JSON, который передается серверу Appium при создании сессии. Подробнее можно почитать в официальной документации. Нас же интересуют конкретные параметры, которые нужны для корректной работы наших тестов. Платформа, на которой будут выполняться тесты (platformName). Название автоматизации (automationName). Для нас значение всегда 'flutter'. Абсолютный путь к исполняемому файлу (app). (только iOS) Название устройства, на котором будут запускаться тесты (deviceName). (только iOS) Версия ОС (platformVersion). (только iOS) Автоподтверждение всех диалоговых окон - запросы разрешений и т.п. (autoAcceptAlerts). (только Android) Автоподтверждение всех запросов разрешений (autoGrantPermissions). Создадим файл driver_setup.py. В нем будет происходить инициализация сессии и устанавливаться capabilities в зависимости от платформы. Теперь создадим наш собственный класс для тест-кейсов, который будет наследовать питоновский unittest.TestCase. Далее наполняем его методами, которые помогут при написании тестов. В setUp инициализируем драйвер, файндер Флаттера, а также определяем текущую платформу, чтобы в тестах можно было задавать разное поведение в зависимости от платформы. В tearDown просто закрываем запущенное приложение. Один из основных концептов в Appium - контексты. Нативная часть, Flutter, встроенные WebView - Appium использует разную логику и инструменты для работы с каждой из этих частей приложения. Именно эти части и называются контекстами. Если мы рассматриваем Flutter-приложения, контекстов будет три: FLUTTER - само Flutter-приложение или какие-то отдельные компоненты, если Flutter выборочно встраивается в нативное приложение. NATIVE_APP - вся нативная часть, пикеры файлов, нативные диалоги и т.п. WEBVIEW_* - WebView, встроенный в нативное или Flutter-приложение. Для переключения между контекстами используется метод driver.switch_to.context([Идентификатор контекста]). Для удобства работы с контекстами добавим соответствующие методы в наш класс: WebView на Android имеет  идентификатор - 'WEBVIEW_[название пакета]', т.е. 'WEBVIEW_com.example.appium_demo' в нашем случае. На iOS же идентификатор динамический, устанавливаемый при создании WebView. find_webview_context как раз решает эту проблему, находя уже созданный WebView с установленным идентификатором среди всех доступных контекстов приложения. Отметим, что time.sleep и двойной вызов driver.context для ожидания инициализации WebView и поиск первого в списке - довольно грязный хак, но он требуется, т.к. сейчас при первом вызове контекст WebView не всегда корректно подгружается. Теперь поговорим о поиске элементов и взаимодействии с ними. В зависимости от контекста используются два разных способа поиска элементов. При работе во Flutter-контексте используются методы самого Flutter Driver. Поиск элементов выполняется с помощью метода класса FlutterFinder, например, finder.by_text([Текст, отображаемый в виджете]). Доступны следующие условия поиска: by_text(string) - поиск по отображаемому в виджете тексту; by_type(type_name) - поиск по типу виджета; by_tooltip(string) - поиск по всплывающей подсказке; descendant(finder, matching) - поиск виджета, находящегося в другом виджете; и т.п. Все доступные файндеры и команды можно посмотреть в репозитории appium-flutter-driver. Можно написать свои, что еще предстоит сделать дальше. Сам файндер определяет только условия поиска. Для самого выполнения поиска элемента в приложении и взаимодействии с ним используется конструктор класса FlutterElement. После этого можно работать с самим элементом. FlutterElement - это элемент, найденный по условию файндера, с которым можно как-либо взаимодействовать. Элемент можно получить с помощью конструктора класса FlutterElement. Если при работе с контекстом Flutter нужно отдельно создавать условие для файндера, а потом использовать конструктор FlutterElement, то при работе с нативом и вебом такого нет - нужно только использовать метод driver.find_element. В него передается стратегия поиска и искомое значение, например, driver.find_element(AppiumBy.ID, 'com.google.android.apps.photos:id/image'). Enum AppiumBy содержит доступные стратегии поиска для iOS, Android и веба. Вариантов много - идентификаторы объектов, названия классов, XPath, CSS и т.п. Разделений здесь нет, поэтому нужно понимать, какие варианты подходят для текущей платформы и контекста, а какие - нет. Посмотреть подробное описание доступных стратегий можно здесь. Вне зависимости от того, работаем мы с Flutter, нативом или вебом, основные команды для элемента у нас одинаковые, т.к. FlutterElement наследует WebElement. click() - нажатие на элемент send_keys(value) - ввод текста с клавиатуры clear() - очищение поля ввода get_attribute(name) - получение значения атрибута элемента Не все элементы на экране появляются мгновенно. Элементы могут отображаться после загрузки данных из сети или из хранилища, диалоги и переходы между экранами происходят с анимациями, приложение может подтормаживать и так далее. Если мы будем искать элементы без ожидания, то тесты могут проваливаться, поэтому часто нам нужно ждать появления элемента. Самый банальный вариант - жесткое ожидание с помощью time.sleep([секунды]), но это будет излишне замедлять тесты при появлении элементов раньше или заставлять их падать, если элемент появится позже установленного времени ожидания. Оптимальным вариантом будет ожидание ровно до того момента, пока элемент не появится, но не дольше заданного таймаута. Для Flutter есть команда waitFor, а для натива и веба есть класс WebDriverWait. В обоих случаях при превышении таймаута выбрасывается исключение. Эту функциональность можно использовать для того, чтобы проверять, есть ли элемент на экране. Напишем два метода, использующих эту функциональность. Они будут возвращать bool в зависимости от наличия элемента на экране. Теперь можно написать методы получения элементов: Благодаря этим методам в тестах мы сможем писать короткий и понятный код. Спустя пару часов приготовлений мы наконец-то можем написать наш первый тест-кейс :) Создадим файл с тестами test_home_screen.py. В него добавим класс HomeScreenTest(BaseTestCase) и напишем первый кейс - открытие диалога добавления новой записи при нажатии FAB-кнопки “Добавить”. Необходимо определить параметры поиска этого элемента. Самый простой способ сделать это - узнать необходимую информацию о виджете через Flutter DevTools. Запускаем приложение командой flutter run и переходим по ссылке на DevTools, появившейся в консоли. В открывшемся окне браузера включаем “Select Widget Mode” и нажимаем в симуляторе на нужный нам виджет или ищем его вручную в дереве виджетов. Так, нажав на кнопку “Добавить”, искомый виджет фокусируется в дереве, и мы можем просмотреть всю необходимую информацию о нем, например, тип виджета - FloatingActionButton. Используем название типа для поиска элемента. Находим кнопку “Добавить” по её типу, нажимаем на неё, а затем проверяем успешное открытие диалога создания новой записи. Благодаря тому, что ожидается появление элемента на экране в течение 5 секунд, тест проходит успешно. Без этого ожидания проверка выполнялась бы мгновенно, и диалог не успевал бы появиться на экране. Запустим Appium и тесты. Проследим, что наш единственный тест выполняется успешно. Теперь напишем тест на выбор изображения из галереи. Здесь будем взаимодействовать с нативными компонентами самой ОС, и напишем сценарии для прогона и на iOS, и на Android. Для того, чтобы определить возможные условия поиска нативных элементов, отлично подходит Appium Inspector - еще один клиент для Appium, который позволяет в удобном UI взаимодействовать с тестируемым приложением. Загружаем приложение с GitHub и запускаем. Перед нами откроется окно, где можно задать параметры соединения и необходимые capabilities. Указываем такие же параметры, как и в нашем проекте. Нажимаем “Start session”. После этого на симуляторе должно запуститься приложение, а в окне Инспектора отобразится активная сессия. Слева теперь видим окно с приложением, в котором на данный момент будет бесконечная загрузка. Appium Inspector не поддерживает контекст Flutter, в котором запускается наше приложение, и считает, что приложение работает в нативном контексте. Сверху переключаем контекст на “Hybrid”, и обратно на “Native”, после чего приложение должно успешно отобразиться в окне. Открываем диалог добавления записи, нажимаем кнопку “Выбрать файл”. Обновляем данные в инспекторе и выбираем желамое изображение. В первом разделе получим возможные варианты для поиска элемента. С Андроидом все работает аналогично. Меняем capabilities в конфигурации, запускаем приложение на эмуляторе и открываем пикер файлов. Видим доступные варианты поиска элементов. Переходим к написанию теста. Создаём новый тест и, на основе текущей платформы, используем разные условия поиска. Не забываем переключиться между контекстами. Теперь можем запустить тесты на обеих платформах и проверить, они корректно выполняют все действия. Отметим, что здесь жестко указано, какой файл нужно выбирать в пикере. Если этот конкретный файл будет отсутствовать на устройстве, тест упадет. Вместо точных данных о элементе, можно использовать XPath или iOS Class Chain, которые позволяют, например, выбирать элемент по его порядку в списке. Пишем тест на ввод названия записи. Здесь всё просто. Открываем диалог, вводим текст в поле, проверяем наличие текста. Возьмем пример с добавленной записью. Кнопка удаления записи представлена в виде одинокой иконки. Найти эту кнопку можно через файндер descendant или по типу элемента. В нашем случае сделаем именно поиск по иконке, чтобы показать, как делать собственные файндеры. Подробнее про написание файндеров и команд можно посмотреть в официальной документации Flutter.  Поиск иконок реализуем по их значению - строкам вида 'IconData(U+0F697)'. Эти значения можно найти при анализе виджетов с помощью Flutter DevTools. Создаём файл icon_finder.dart, который будет выступать моделью данных этого файндера: Добавляем файл icon_finder_extension.dart. В нем будет содержаться логика парсинга данных из JSON, которые будут поступать в flutter_driver из Appium, а также сама логика поиска виджета с нужной иконкой. Указываем это расширение в методе enableFlutterDriverExtension, чтобы flutter_driver его использовал: Теперь можно пересобрать приложение и использовать новый файндер в наших тестах, например, в тесте на удаление записи. Напишем тест поведения кнопки “Найти в Wikipedia” у добавленной записи. После нажатия на нее должен открываться поиск Википедии, в который уже предустановлено название записи. В Appium Inspector есть возможность работать с WebView, но она достаточно ограничена. Даже сам Инспектор предупредит об этом следующим сообщением: Следуем совету в сообщении и переходим на нужную страницу в Chrome вручную и открываем DevTools. Далее выбираем, по какому критерию будем искать элемент. Если ID элемента статичный, то XPath - самый простой вариант. В текущем примере используем именно его. Далее нам нужно проверить значение, установленное в строке поиска, поэтому скопируем XPath для нее - //*[@id="ooui-php-1"]. Пишем тест: Заметим, что этот тест корректно выполняется на iOS, но на Android - падает. Это происходит из-за того, что для управления WebView используется отдельное приложение Chromedriver, которое необходимо добавить в наш проект. Для этого нужно запустить Appium Server c параметрами, позволяющими автоматическую загрузку Chromedriver. В capabilities для Android нужно указать путь к Chromedriver, куда он будет скачиваться и откуда будет использоваться. Я добавил этот путь в .env, чтобы можно было легко его менять на разных ПК. Теперь можно снова запустить тесты и удостовериться, что наш новый тест для WebView корректно выполняется. На этом написание тест-кейсов можно считать законченным. Мы рассмотрели, как использовать Appium для автоматического тестирования приложений на Flutter. Описанные тест-кейсы покрывают основные сценарии взаимодействия с приложением, как с Flutter-частью, так и в гибридном режиме вместе с нативом. Надеюсь, гайд послужит хорошей отправной точкой для работы с Appium в вашем проекте. Если у вас остались вопросы или есть дополнения - приглашаю в комментарии. Полный код проекта можно посмотреть в репозитории. Удачного тестирования!  Flutter-разработчик