Туториалы

Maestro + Flutter: E2E-тесты на YAML без страданий

Что такое Maestro для мобильного тестирования?

Maestro — это open-source фреймворк для мобильного E2E-тестирования, который использует YAML-файлы и тестирование в режиме чёрного ящика через дерево доступности устройства — без тест-кода в исходниках приложения и без перекомпиляции. Поддерживает iOS и Android с нативной поддержкой Flutter, первый тест запускается менее чем за 5 минут.

TL;DR

  • -Maestro работает через дерево доступности в режиме чёрного ящика — никакого Dart-кода, никакой перекомпиляции приложения
  • -Первый тест запускается за 5 минут: установить CLI, написать YAML-файл, выполнить `maestro test` — без сборки Gradle или Xcode
  • -Билингвальные regex-матчеры (например, `"New journey|Новое путешествие"`) покрывают локализованный UI без дублирования файлов тестов
  • -На iOS нужен workaround с companion-приложением `maestro_driver`; performance gates автоматически ловят регрессии скорости
  • -AI-ассерты через `assertWithAI` справляются с динамическим контентом, где точное совпадение строк давало бы ложные срабатывания

Мобильные E2E-тесты — больная тема. Appium тянет за собой Selenium, Java и часы настройки. Detox работает только с React Native. Встроенный integration_test Flutter не может нажать системный диалог. Patrol хорош, но требует доступ к исходникам.

Maestro работает иначе: YAML-файлы, чёрный ящик, никакого Dart-кода в тестах. Дальше — паттерны и грабли, набранные при тестировании Flutter-приложения с десятками экранов и AI-фичами. Что работает, что приходится обходить, где Maestro экономит время, а где добавляет головной боли.

Зачем Maestro, когда есть всё остальное

Инструментов для мобильного E2E хватает. Вопрос — какой из них подходит для Flutter-проекта, где один-два разработчика и нет отдельной QA-команды.

ФреймворкGitHub starsFlutterЯзык тестовОсобенность
Appium21.2kчерез плагинлюбой (WebDriver)тяжёлый, enterprise
Detox11.8kнетJS/TSReact Native only
Patrol1.2kнативный (Dart)Dartтребует исходники
integration_testFlutter SDKнативныйDartне видит OS-диалоги
Maestro10.8kfirst-classYAMLчёрный ящик

Appium — стандарт для больших команд с выделенной автоматизацией. Настроить его с Flutter можно, но appium-flutter-driver добавляет ещё один слой абстракции поверх и без того непростого стека.

Detox отпадает сразу — React Native only.

Patrol — сильный вариант для Flutter. Написан на Dart, расширяет integration_test, умеет работать с системными диалогами. С версии 4.0 поддерживает и Web. Но тесты пишутся на Dart, запускаются внутри процесса приложения (gray-box). Для кого-то это плюс, для кого-то — лишняя связность.

integration_test из Flutter SDK — минимум зависимостей, Dart, gray-box. Но всё, что за пределами приложения — permission dialogs, push notifications, системные переключатели — недоступно.

Maestro работает через accessibility tree. Не знает про Flutter, Dart или виджеты. Видит то, что видит пользователь. Тесты — YAML. Порог входа минимальный: установил CLI, написал файл, запустил.

curl -fsSL "https://get.maestro.mobile.dev" | bash
# или macOS:
brew tap mobile-dev-inc/tap && brew install mobile-dev-inc/tap/maestro

Первый тест: от нуля до зелёного за 5 минут

Maestro flow — это YAML-файл со списком команд. Каждая команда — действие пользователя или проверка.

appId: com.example.myapp
---
- launchApp
- assertVisible: "Welcome"
- tapOn: "Sign Up"
- assertVisible: "Create an account"

Запуск:

maestro test smoke_test.yaml

Maestro запускает приложение на подключённом устройстве или симуляторе, выполняет шаги по порядку, падает при первом несовпадении. Без Gradle, без Xcode, без сборки тестового runner’а.

Чуть более реальный пример — smoke-тест с тегами и зависимостью на логин:

appId: com.example.myapp
tags:
  - smoke
---
- runFlow: ../auth/login_flow.yaml

- assertVisible:
    text: "New journey|Новое путешествие"

- tapOn:
    text: "New journey|Новое путешествие"

- waitForAnimationToEnd

- extendedWaitUntil:
    visible:
      text: "Choose destination|Выберите направление"
    timeout: 10000

- assertVisible:
    text: "Create with AI|Создать с помощью AI"

runFlow подключает логин как зависимость — об этом ниже. text: "EN|RU" — regex-матчер для мультиязычных приложений.

Flutter-специфика: как Maestro видит виджеты

Maestro работает через accessibility bridge — слой, который Flutter предоставляет для screen readers и автоматизации. Это значит: Widget Keys (Key('my_button')) не работают. Вообще. Issue #128, открытый с 2022 года, формально закрыт — но нативной поддержки Keys так и нет. Решение пришло с другой стороны: Semantics.identifier.

Semantics.identifier — правильный способ

Команда Maestro внесла Semantics.identifier прямо во Flutter SDK. Фича появилась в Flutter 3.19 (февраль 2024). Identifier попадает в accessibility tree и доступен в Maestro как id.

Semantics(
  identifier: 'create_button',
  child: FloatingActionButton(
    onPressed: _onCreate,
    child: Icon(Icons.add),
  ),
)
- tapOn:
    id: "create_button"

Identifier не зависит от языка, не конфликтует с текстовым контентом, стабилен при переименовании кнопки. Для иконок без текста — semanticLabel:

Icon(Icons.search, semanticLabel: 'Search')

BottomNavigationBar: координатный fallback

Flutter рендерит BottomNavigationBar как единый accessibility-элемент. Maestro не может нажать на отдельную вкладку по тексту — все вкладки склеены в один контейнер.

Обходной путь — координаты:

- tapOn:
    point: "10%,93%"  # первая вкладка, нижний край экрана

Процентные координаты работают на разных размерах экрана. Не идеально, но для навигационной панели с фиксированной позицией — достаточно стабильно.

iOS: secure text fields теряют символы

На iOS Maestro вводит текст через IME (Input Method Editor). Поля с obscureText: true — пароли — теряют символы при быстром вводе. Flutter перестраивает виджет при каждом символе, и IME не успевает.

Обход: переключить видимость пароля до ввода текста.

- tapOn:
    text: "Password|Пароль"

# Тапнуть иконку "глаз" -- переключить в обычное текстовое поле
- tapOn:
    text: "Show password"

- waitForAnimationToEnd

# Теперь вводим текст в visible (не secure) поле
- eraseText: 50
- inputText: "${TEST_PASSWORD}"

Без этого шага тест вводит “TestP” вместо “TestPassword123!” — и логин молча падает.

Билингвальные тесты: один flow, два языка

Если приложение мультиязычное и язык определяется системной локалью, писать два набора тестов — двойная работа и двойная поддержка.

Maestro использует regex для матчинга текста. Pipe | — это «или»:

- tapOn:
    text: "Skip|Пропустить"

- assertVisible:
    text: "Create an account|Создать аккаунт"

- extendedWaitUntil:
    visible:
      text: ".*afternoon.*|.*morning.*|.*evening.*|.*день.*|.*утро.*|.*вечер.*"
    timeout: 30000

Regex матчится по полному тексту элемента. Если на кнопке написано “Do you have an account? Login”, нужен .*Login.* вначале) — иначе не совпадёт.

Ловушка с regex: .*OK.* матчит слово “Tokyo”. Regex не знает про границы слов. Тест нажимает не ту кнопку и уходит на другой экран. Решение — конкретный текст: "Понятно|Got it" вместо .*OK.*. Чем точнее матчер, тем стабильнее тест.

Надёжность: sleep, таймауты и graceful degradation

Пауза без sleep

У Maestro нет команды sleep. Осознанный выбор — sleep делает тесты хрупкими. Но иногда пауза нужна: асинхронная инициализация после запуска, задержка бекенда, анимация, которая не ловится waitForAnimationToEnd.

Обходной путь — extendedWaitUntil с несуществующим элементом:

- extendedWaitUntil:
    visible:
      id: "__never_matches__"
    timeout: 5000
    optional: true

Maestro ждёт 5 секунд, не находит элемент, optional: true не даёт тесту упасть. Выглядит как хак — и это хак. Но работает предсказуемо, и намерение понятно из кода.

optional: true — тесты, которые не ломаются на мелочах

optional: true превращает жёсткий ассерт в мягкий. Элемент не найден — шаг пропускается, тест продолжается.

# Tooltip может появиться, а может нет
- tapOn:
    text: ".*Got it.*|.*Понятно.*"
    optional: true

Без этого флага случайные тултипы и модалки роняют прогон через раз. С ним — проходит стабильно, проверяя только критический путь.

Правило: hard assert (optional: false, по умолчанию) для ключевых проверок. Soft assert (optional: true) для вариативных элементов — тултипов, onboarding-подсказок, промо-баннеров.

Performance gates: таймауты как спецификация

Если приложение работает с AI-бекендом или тяжёлыми запросами, таймауты превращаются в контракт на производительность:

# Первый отклик от AI за 45 секунд
- extendedWaitUntil:
    visible:
      text: ".*Searching.*|.*Processing.*"
    timeout: 45000

# Полный результат за 120 секунд
- extendedWaitUntil:
    visible:
      text: ".*results.*|.*Done.*"
    timeout: 120000

Бекенд деградировал и ответ пришёл за 130 секунд — тест падает. Таймаут тут — не «магическое число», а зафиксированное требование к скорости.

Архитектура тестового набора

runFlow: DRY для общих зависимостей

Логин — зависимость почти всех тестов. runFlow подключает его как подпрограмму:

- runFlow: ../auth/login_flow.yaml

Один файл логина, десятки тестов его используют. Изменился flow авторизации — правка в одном месте. runFlow работает и для других повторяющихся блоков: навигация к нужному экрану, dismiss onboarding, подготовка тестовых данных. По сути — аналог setUp() из xUnit, только в YAML.

Теги и селективный запуск

Каждый flow-файл можно тегировать, а потом запускать подмножества:

tags:
  - smoke      # быстрые проверки критических путей
  - critical   # то, что не должно ломаться никогда
  - slow       # тесты с AI или тяжёлыми запросами
maestro test --tags smoke     # smoke перед коммитом, ~2 минуты
maestro test --tags critical  # critical перед мержем, ~5 минут
maestro test                  # полный набор перед релизом

Persistence check: данные переживают перезапуск

Как проверить, что данные сохраняются между перезапусками приложения:

# Шаг 1: изменить данные (отметить задачу, добавить в избранное)
- tapOn:
    text: ".*task name.*"
    optional: true

# Шаг 2: перезапустить приложение БЕЗ clearState
- launchApp
  # clearState НЕ указан -- файловая система сохраняется

# Шаг 3: проверить, что данные на месте
- assertVisible:
    text: ".*task name.*"

launchApp без clearState убивает процесс, но сохраняет файловую систему приложения — токены, кеш, локальную базу. Данные приходят с сервера заново. Если после перезапуска что-то пропало — проблема в синхронизации с бекендом, и тест её поймает.

Изоляция: уникальные тестовые пользователи

Maestro поддерживает JavaScript-выражения. Генератор уникальных email’ов в две строки:

const ts = Date.now();
output.email = `e2e${ts}@test.example.com`;
output.password = 'TestPass123!';

Каждый запуск — чистый пользователь. Нет конфликтов при параллельных запусках, нет мусора от предыдущих тестов.

AI в Maestro

Начиная с версии 1.39 Maestro поддерживает три AI-команды:

assertWithAI — ожидание описывается на естественном языке, Maestro делает скриншот и отправляет LLM для верификации:

- assertWithAI:
    assertion: "The screen shows a login form with email and password fields"

assertNoDefectsWithAI — автоматический поиск визуальных дефектов (обрезанный текст, наложение элементов, сломанная вёрстка):

- assertNoDefectsWithAI

extractTextWithAI — извлечение текста из скриншота через LLM. Полезно для динамического контента, который сложно поймать regex’ом.

Все три команды экспериментальные. Работают через OpenAI-совместимые API — можно подключить собственную модель или использовать дефолтный бекенд Maestro Cloud.

Maestro MCP: AI-агент пишет и отлаживает тесты

В Maestro встроен MCP-сервер, запускается через maestro mcp без дополнительных пакетов. 13 инструментов: take_screenshot, inspect_view_hierarchy, run_flow, tap_on, input_text, check_flow_syntax, query_docs.

AI-агент (Claude Code, Cursor, Windsurf) получает доступ к живому эмулятору: видит экран, тапает, вводит текст, запускает flow-файлы. Агент смотрит accessibility tree и пишет тесты, проверяя их на реальном устройстве. Цикл «написал — запустил — поправил» занимает минуты вместо часов ручной возни с YAML.

Где Maestro буксует

Maestro закрывает много задач, но добавляет свои.

Widget Keys не работают

Key('my_widget') во Flutter-коде не попадает в accessibility tree. Maestro не может их использовать для адресации. Open issue с 2022 года. Решение — Semantics(identifier:), но это требует добавлять accessibility-разметку в production-код. Одним это в плюс (accessibility by default), другим — лишний код.

Нет sleep

Философски правильно. Практически — неудобно. Хак с extendedWaitUntil + __never_matches__ работает, но каждый новый человек в команде спрашивает «что это за магия».

Обратная совместимость ломается

Между версиями пропадают команды. wait: <number> убрали, regex: true убрали. При мажорном обновлении приходится ревизировать все flow-файлы. Для большого тестового набора стоит написать скрипт миграции заранее.

CI — не бесплатно

Два варианта:

Maestro Cloud — загружаешь APK/IPA и flow-файлы, тесты крутятся на облачных устройствах. GitHub Action mobile-dev-inc/action-maestro-cloud@v2.0.2. Удобно, но платно.

Self-hosted — ставишь Maestro CLI на CI-runner. Для Android — любой runner с ADB. Для iOS — macOS runner. Параллелизация через --shard-split N / --shard-all N (разбивает тесты на N устройств). Бесплатно, но требует инфраструктуры.

Для маленькой команды без CI можно долго жить на локальных запусках и MCP. Но с ростом команды CI становится необходимостью.

WebView на iOS

Maestro не видит элементы внутри WebView на iOS (issue #2293). На Android есть workaround через Chrome DevTools Protocol (androidWebViewHierarchy: devtools), на iOS — нет. Если приложение активно использует WebView, это блокер.

Flutter Desktop не поддерживается

Только мобильные платформы (Android, iOS) и Web (с v2.0). macOS, Windows, Linux — нет.

Что дальше

Если в проекте Flutter и нет E2E-тестов — с Maestro можно начать за вечер. Установить CLI, написать login flow, запустить. Без Xcode runners, без Gradle tasks, без тестовых зависимостей в pubspec.yaml.

Три шага для старта:

  1. Добавить Semantics(identifier:) к основным элементам. Кнопки действий, поля ввода, навигация. 10-15 идентификаторов покроют основные flow.

  2. Написать login flow и smoke-тесты. Логин как переиспользуемый runFlow. Smoke по одному на экран: открылся, ключевые элементы видны.

  3. Подключить Maestro MCP к IDE. maestro mcp даёт AI-агенту доступ к живому эмулятору. Агент видит экран, пишет flow, тут же проверяет.

Дальше — по потребности: теги для разных наборов, performance gates, persistence checks, CI. Всё необязательно на старте.

FAQ

Как Maestro сравнивается с Patrol по надёжности для Flutter-приложений с тяжёлыми анимациями?

Patrol работает внутри процесса приложения (gray-box) и может напрямую вызвать tester.pumpAndSettle() для ожидания стабилизации дерева виджетов — это делает его надёжнее при сложных анимациях. Maestro работает через accessibility tree снаружи, поэтому waitForAnimationToEnd и extendedWaitUntil с запасным таймаутом — единственные инструменты. На практике Flutter-приложения с переходами дольше 600 мс регулярно требуют буферов 2 000–3 000 мс в Maestro, чтобы избежать нестабильности, которую Patrol обработал бы детерминированно.

Что происходит с состоянием тестов при параллельном запуске через --shard-split?

Каждый шард получает своё устройство и запускает назначенные flow-файлы независимо. Разделяемого состояния между шардами нет — каждый flow стартует с чистого устройства, если только flow явно не переиспользует состояние через launchApp без clearState. Рекомендуемый паттерн при параллельных запусках — уникальные тестовые пользователи на каждый flow (email на основе timestamp), чтобы избежать конфликтов на стороне сервера. Количество шардов ограничено числом доступных устройств или эмуляторов, а не количеством CPU-ядер.

Можно ли использовать assertWithAI в CI без оплаты Maestro Cloud?

Да — AI-команды используют OpenAI-совместимые API, которые вы настраиваете сами через переменные окружения MAESTRO_AI_*. Можно указать любой совместимый эндпоинт: собственный деплой, Groq или локальную модель через Ollama. Скриншоты отправляются на тот эндпоинт, который вы настроили, без обязательной маршрутизации через Maestro Cloud. Стоимость одного вызова assertWithAI с GPT-4o составляет примерно 2–5 центов в зависимости от разрешения скриншота.