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 stars | Flutter | Язык тестов | Особенность |
|---|---|---|---|---|
| Appium | 21.2k | через плагин | любой (WebDriver) | тяжёлый, enterprise |
| Detox | 11.8k | нет | JS/TS | React Native only |
| Patrol | 1.2k | нативный (Dart) | Dart | требует исходники |
| integration_test | Flutter SDK | нативный | Dart | не видит OS-диалоги |
| Maestro | 10.8k | first-class | YAML | чёрный ящик |
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.
Три шага для старта:
-
Добавить
Semantics(identifier:)к основным элементам. Кнопки действий, поля ввода, навигация. 10-15 идентификаторов покроют основные flow. -
Написать login flow и smoke-тесты. Логин как переиспользуемый
runFlow. Smoke по одному на экран: открылся, ключевые элементы видны. -
Подключить 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 центов в зависимости от разрешения скриншота.