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

> Maestro для Flutter-приложений: билингвальные regex-матчеры, iOS workarounds, performance gates, AI-ассерты. Паттерны и грабли из production-проекта.
> Author: Roman Belov · Published: 2026-02-24 · Source: https://futurecraft.pro/ru/blog/maestro-flutter-e2e-testing/

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

Maestro работает иначе: YAML-файлы, чёрный ящик, никакого Dart-кода в тестах. Дальше -- паттерны и грабли, набранные при тестировании [Flutter-приложения](/ru/blog/ai-coding-flutter-mobile/) с десятками экранов и AI-фичами. Что работает, что приходится обходить, где Maestro экономит время, а где добавляет головной боли.

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

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

| Фреймворк | GitHub stars (на июль 2026) | Flutter | Язык тестов | Особенность |
|-----------|-------------|---------|-------------|-------------|
| [Appium](https://github.com/appium/appium) | 21.2k | через плагин | любой (WebDriver) | тяжёлый, enterprise |
| [Detox](https://github.com/wix/Detox) | 11.8k | нет | JS/TS | React Native only |
| [Patrol](https://github.com/leancodepl/patrol) | 1.2k | нативный (Dart) | Dart | требует исходники |
| integration_test | Flutter SDK | нативный | Dart | не видит OS-диалоги |
| **[Maestro](https://github.com/mobile-dev-inc/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, написал файл, запустил.

```bash
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-файл со списком команд. Каждая команда -- действие пользователя или проверка.

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

Запуск:

```bash
maestro test smoke_test.yaml
```

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

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

```yaml
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](https://github.com/mobile-dev-inc/maestro/issues/128), открытый с 2022 года, формально закрыт -- но нативной поддержки Keys так и нет. Решение пришло с другой стороны: `Semantics.identifier`.

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

Команда Maestro [внесла](https://maestro.dev/blog/the-power-of-open-source-making-maestro-work-better-with-flutter) `Semantics.identifier` прямо во Flutter SDK. Фича появилась в Flutter 3.19 (февраль 2024). Identifier попадает в accessibility tree и доступен в Maestro как `id`.

```dart
Semantics(
  identifier: 'create_button',
  child: FloatingActionButton(
    onPressed: _onCreate,
    child: Icon(Icons.add),
  ),
)
```

```yaml
- tapOn:
    id: "create_button"
```

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

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

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

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

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

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

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

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

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

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

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

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

- waitForAnimationToEnd

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

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

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

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

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

```yaml
- 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` с несуществующим элементом:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

```yaml
- runFlow: ../auth/login_flow.yaml
```

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

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

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

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

```bash
maestro test --tags smoke     # smoke перед коммитом, ~2 минуты
maestro test --tags critical  # critical перед мержем, ~5 минут
maestro test                  # полный набор перед релизом
```

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

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

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

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

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

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

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

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

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

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

## AI в Maestro

Начиная с [версии 1.39](https://github.com/mobile-dev-inc/maestro/releases/tag/cli-1.39.0) Maestro поддерживает три [AI-команды](https://docs.maestro.dev/api-reference/commands/assertwithai):

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

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

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

```yaml
- assertNoDefectsWithAI
```

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

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

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

В Maestro [встроен MCP-сервер](https://docs.maestro.dev/get-started/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. Подробнее про [Claude Code](/ru/blog/claude-code-complete-guide/) и [MCP-серверы](/ru/blog/mcp-production-custom-servers/) — в отдельных гайдах.

## Где 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](https://docs.maestro.dev/maestro-cloud/ci-cd-integration/github-actions) `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](https://github.com/mobile-dev-inc/Maestro/issues/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. Всё необязательно на старте.

---

*Нужна помощь с E2E-тестированием или разработкой Flutter-приложений? Я помогаю стартапам внедрять AI-решения и строить продукты — [belov.works](https://belov.works).*
