Перейти к содержимому

E2E тестирование

Для мобильных E2E тестов используется Maestro — YAML-based фреймворк для автоматизации мобильных приложений.

ХарактеристикаЗначение
Скорость~10-15 сек/тест
ФорматДекларативные YAML-файлы
ДетерминизмДа (в отличие от AI-driven Mobile MCP)
СовместимостьExpo dev build (не Expo Go)
ПлатформаiOS Simulator, Android Emulator

Mobile MCP (через Claude) хорош для ad-hoc проверок (“посмотри что на экране”), но для регрессии не подходит:

  • ~30-60 сек/тест из-за цикла screenshot + AI reasoning
  • Недетерминистичный — AI может интерпретировать экран по-разному
  • Не подходит для CI/CD
Окно терминала
curl -Ls "https://get.maestro.mobile.dev" | bash

Maestro не работает с Expo Go — нужен нативный dev build:

Окно терминала
cd mobile && npx expo run:ios
Окно терминала
bash scripts/e2e-setup.sh
# или вручную:
docker compose up -d --wait
cd backend && npm run seed

Seed создаёт тестового пользователя:

  • Телефон: +7 (700) 123-45-67
  • Пароль: test1234
Окно терминала
cd mobile
# Все E2E тесты
npm run test:e2e
# Только login flow
npm run test:e2e:login
ТестФайлЧто проверяет
Login happy path.maestro/login-flow.yamlВвод телефона, пароль, авторизация, переход на главную
  • Login с неверным паролем (проверка ошибки)
  • Logout flow
  • Просмотр проекта (read-only данные)
  • Чат — отправка сообщения
mobile/
├── .maestro/
│ └── login-flow.yaml # Login happy-path тест
├── package.json # Скрипты test:e2e, test:e2e:login
scripts/
└── e2e-setup.sh # Подготовка Docker + seed

Для Maestro нужны testID на React Native компонентах. Maestro находит элементы по id: селектору, который маппится на testID.

КомпонентtestIDФайл
Login screen containerlogin-screenapp/(auth)/login.tsx
Phone inputlogin-phone-inputapp/(auth)/login.tsx
Password inputlogin-password-inputapp/(auth)/login.tsx
Login buttonlogin-submit-buttonapp/(auth)/login.tsx
Login error textlogin-error-textapp/(auth)/login.tsx
Home screen containerhome-screenapp/(app)/index.tsx
Logout buttonlogout-buttonapp/(app)/index.tsx

testID проброшен в базовые компоненты:

  • Button (components/ui/Button.tsx) — testID на <Pressable>
  • Typography (components/ui/Typography.tsx) — testID на <Text>
  • Input (components/ui/Input.tsx) — testID проходит через ...rest на <TextInput> (extends TextInputProps)

Проблема: React Native на iOS группирует текстовые элементы в один accessibility-узел. Maestro видит один блок вместо отдельных текстов:

# Maestro видит ЭТО:
text='Войдите в личный кабинет, Пароль, Войти, Данные для входа...'
# А НЕ отдельные элементы

Решение: Использовать testID + id: селектор вместо текстового матчинга. Добавить accessible={false} на контейнерные <View>, чтобы дочерние элементы были видны в accessibility-дереве.

// login.tsx — контейнеры с accessible={false}
<TouchableWithoutFeedback accessible={false}>
<KeyboardAvoidingView accessible={false}>
<View accessible={false} style={styles.header}>
<View accessible={false} style={styles.form}>
// Input.tsx — wrapper с accessible={false}
<View accessible={false}>
<View accessible={false} style={styles.container}>
<TextInput testID={testID} />

Проблема: expo-secure-store хранит JWT токены в iOS Keychain, а не в sandbox. clearState очищает только sandbox.

КомандаЧто чиститЭффект
clearStateApp sandbox (UserDefaults, файлы)Токены в Keychain остаются
clearKeychainiOS KeychainЛомает Expo runtime (очищает всё)

Решение: Не использовать clearKeychain. Вместо этого — если приложение уже залогинено, нажать кнопку “Выйти” через optional tap:

- clearState
- launchApp
- waitForAnimationToEnd
- waitForAnimationToEnd
# Если уже залогинены — выйти
- tapOn:
id: "logout-button"
optional: true

Проблема: Поле телефона использует keyboardType="phone-pad" (только цифры). hideKeyboard не работает с phone-pad на iOS.

Решение: Тап по пустой области экрана (logo area) для dismiss:

- tapOn:
point: "50%,15%"
- waitForAnimationToEnd

Проблема: Если клавиатура симулятора в русской раскладке, Maestro inputText не может ввести латинские символы (пароль “test1234” → вводится только “1234”).

Решение: Перед вводом пароля тапнуть иконку глобуса (🌐) для переключения на английскую раскладку:

- tapOn:
id: "login-password-input"
- waitForAnimationToEnd
# Переключить клавиатуру на English
- tapOn:
point: "12%,96%"
- waitForAnimationToEnd
- inputText: "test1234"

Проблема: Поле телефона стартует со значением “7” (отображается как “+7”). При eraseText → поле пустеет → handlePhoneChange("") сбрасывает state обратно на “7” → поле снова “+7”.

Решение: Не стирать, а дописывать оставшиеся 10 цифр:

- tapOn:
id: "login-phone-input"
- inputText: "7001234567" # Только оставшиеся 10 цифр

В процессе настройки E2E обнаружен баг: после directus.login() AuthGate не обновлял состояние authenticated, что приводило к redirect обратно на login.

Решение: Создан AuthProvider (providers/AuthProvider.tsx) с методами signIn() / signOut(), который управляет auth-состоянием через React Context. Login screen вызывает signIn() после успешного логина, AuthProvider обновляет state и роутер перенаправляет на /(app).

Часто используемые команды в наших тестах:

# Запуск и очистка
- clearState # Очистка sandbox (не keychain!)
- launchApp # Запуск приложения
# Ожидание
- waitForAnimationToEnd # Ждать окончания анимации
- extendedWaitUntil: # Ждать появления элемента
visible:
id: "element-id"
timeout: 15000
# Взаимодействие
- tapOn:
id: "test-id" # Тап по testID
- tapOn:
point: "50%,15%" # Тап по координатам (% экрана)
- tapOn:
id: "element"
optional: true # Пропустить если не найден
# Ввод текста
- inputText: "текст" # Ввод через клавиатуру
- eraseText: 20 # Удалить N символов (backspace)

Debug-артефакты сохраняются в ~/.maestro/tests/YYYY-MM-DD_HHMMSS/:

  • screenshot-*.png — скриншот в момент ошибки
  • commands-*.json — иерархия элементов для каждого шага
  • maestro.log — подробный лог

Для просмотра accessibility-иерархии из JSON:

Окно терминала
cat commands-*.json | python3 -c "
import json, sys
data = json.load(sys.stdin)
for cmd in data:
if cmd.get('metadata',{}).get('status') == 'FAILED':
def find(node, depth=0):
a = node.get('attributes', {})
t, i = a.get('accessibilityText',''), a.get('resource-id','')
if t or i: print(f\"{' '*depth}text={t!r} id={i!r}\")
for c in node.get('children', []): find(c, depth+1)
find(cmd['metadata']['error']['hierarchyRoot'])
"