Мобильное приложение проверяют на трёх уровнях: unit-тесты для логики, компонентные (в Flutter — widget-тесты) для экранов и E2E-тесты на эмуляторе или устройстве для ключевых сценариев. В React Native и Expo первые два уровня закрывают Jest и React Native Testing Library, во Flutter — пакет flutter_test; для E2E берут Maestro, Detox или integration_test. Автотесты не заменяют ручную проверку на реальных устройствах и тестовые сборки через TestFlight и внутреннее тестирование Google Play.
После материала вы сможете
- разложить проверки мобильного приложения по уровням: unit, компонентные, E2E и ручные на устройствах
- настроить
jest-expoи React Native Testing Library в проекте Expo и написать компонентный тест черезuserEvent - написать unit- и widget-тест во Flutter на
flutter_testи запустить их командойflutter test - составить матрицу устройств и выбрать тестовый канал: TestFlight или внутреннее тестирование Google Play
Что знать заранее
Проверено на
Expo SDK 57React Native 0.86React 19.2jest-expo 57.0Jest 29.7@testing-library/react-native 14.1TypeScript 6.0Flutter 3.44 (Dart 3.12)Node.js 24 LTS (24.18)
Сверено с документацией: 11 октября 2026 г.
Содержание
- Чем тестирование мобильного приложения отличается от веба
- Уровни тестов: что где запускается
- React Native и Expo: Jest и React Native Testing Library
- Flutter: unit- и widget-тесты на flutter_test
- E2E: Maestro, Detox и integration_test
- Ручное тестирование: матрица устройств и тестовые сборки
- Чек-лист перед релизом и упражнения
Чем тестирование мобильного приложения отличается от веба
Сайт открывается в браузере, который обновляется сам, и исправление доезжает до пользователя после деплоя. Мобильное приложение живёт иначе: его собирают под две платформы, проводят через модерацию сторов, а пользователь может месяцами не обновляться. Ошибка, попавшая в релиз, стоит дороже — исправление снова проходит сборку и ревью.
- Устройства: разные размеры экранов, вырезы, плотность пикселей, объём памяти, старые и новые процессоры.
- Версии ОС: Android и iOS по-разному показывают системные диалоги, клавиатуры и разрешения.
- Сеть: мобильный интернет пропадает в лифте и метро — экран должен пережить таймаут и повтор запроса.
- Жизненный цикл: приложение сворачивают, система выгружает его из памяти, пользователь возвращается через час.
- Разрешения и нативные модули: камера, геолокация, уведомления работают только в настоящей сборке, а не в тестовой среде Node.js.
Отсюда главный принцип: быстрые автотесты ловят ошибки в логике и экранах ещё на компьютере разработчика, а то, что зависит от железа и ОС, проверяют на эмуляторах и реальных устройствах. Общая идея распределения тестов по уровням разобрана в материале о пирамиде тестирования; здесь — как она выглядит в мобильной разработке.
Уровни тестов: что где запускается
Названия уровней у платформ немного разные, но смысл общий. Во Flutter компонентный тест называется widget-тестом; в Android-документации тесты делят на локальные (на компьютере) и инструментальные (на устройстве или эмуляторе).
Статический анализ
- Что проверяет
- Типы и типичные ошибки без запуска
- Где запускается
- Компьютер, CI
- React Native / Expo
- TypeScript, ESLint
- Flutter
flutter analyze
Unit
- Что проверяет
- Функцию или класс: форматирование, валидацию, расчёты
- Где запускается
- Компьютер, CI
- React Native / Expo
- Jest
- Flutter
test()изflutter_test
Компонентный
- Что проверяет
- Экран или компонент: что видно, что происходит по нажатию
- Где запускается
- Компьютер, CI (без устройства)
- React Native / Expo
- Jest + React Native Testing Library
- Flutter
testWidgets()(widget-тест)
E2E
- Что проверяет
- Сценарий целиком в собранном приложении
- Где запускается
- Эмулятор, симулятор, устройство, облачная ферма
- React Native / Expo
- Maestro, Detox
- Flutter
integration_test, Maestro, Patrol
Ручной
- Что проверяет
- Вёрстку на реальных экранах, жесты, системные диалоги, ощущения
- Где запускается
- Реальные устройства
- React Native / Expo
- TestFlight, внутреннее тестирование Google Play
- Flutter
- То же
Документация Flutter сводит компромисс в простую таблицу: unit-тесты дают низкую уверенность, но дёшевы и быстры; widget-тесты увереннее и всё ещё быстрые; интеграционные дают максимальную уверенность, но медленные и дорогие в поддержке. Рекомендация Flutter — много unit- и widget-тестов с отслеживанием покрытия и столько интеграционных, сколько нужно для важных сценариев. React Native советует закрывать E2E жизненно важные потоки: вход, основные функции, оплату.
React Native и Expo: Jest и React Native Testing Library
В проекте Expo тесты строятся на Jest с пресетом jest-expo — он подменяет нативную часть Expo SDK, чтобы компоненты рендерились в Node.js. Экраны рендерит и опрашивает React Native Testing Library (RNTL). Старый react-test-renderer устарел и, по документации Expo, не поддерживает React 19 и новее — удалите его, если он остался в проекте.
npx create-expo-app@latest app --template blank-typescript
cd app
npx expo install jest-expo jest @types/jest @testing-library/react-native --devnpx expo install подбирает версии, совместимые с SDK{
"scripts": {
"test": "jest --watchAll",
"test:ci": "jest"
},
"jest": {
"preset": "jest-expo"
}
}test — режим наблюдения, test:ci — один прогон для CI{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"types": [
"jest"
]
}
}Пример — форма «перезвоните мне», типичная для приложений сервисного бизнеса. Логику нормализации номера выносим в отдельную функцию: её проверяет быстрый unit-тест, а форма отвечает только за интерфейс.
// Нормализует казахстанский номер к виду +7XXXXXXXXXX или возвращает null
export function normalizePhone(input: string): string | null {
const digits = input.replace(/\D/g, "");
if (digits.length === 11 && (digits.startsWith("7") || digits.startsWith("8"))) {
return `+7${digits.slice(1)}`;
}
if (digits.length === 10) {
return `+7${digits}`;
}
return null;
}import { useState } from "react";
import { Pressable, Text, TextInput, View } from "react-native";
import { normalizePhone } from "../lib/phone";
type Props = {
onSubmit: (phone: string) => void;
};
export function CallbackForm({ onSubmit }: Props) {
const [value, setValue] = useState("");
const [error, setError] = useState<string | null>(null);
function handlePress() {
const phone = normalizePhone(value);
if (!phone) {
setError("Проверьте номер телефона");
return;
}
setError(null);
onSubmit(phone);
}
return (
<View>
<TextInput
testID="phone-input"
accessibilityLabel="Телефон"
keyboardType="phone-pad"
value={value}
onChangeText={setValue}
/>
{error ? <Text role="alert">{error}</Text> : null}
<Pressable testID="submit-button" role="button" onPress={handlePress}>
<Text>Перезвоните мне</Text>
</Pressable>
</View>
);
}alert и кнопка. testID пригодится для E2Eimport { normalizePhone } from "../lib/phone";
describe("normalizePhone", () => {
it("приводит разные записи к +7XXXXXXXXXX", () => {
expect(normalizePhone("8 (701) 123-45-67")).toBe("+77011234567");
expect(normalizePhone("+7 701 123 45 67")).toBe("+77011234567");
expect(normalizePhone("7011234567")).toBe("+77011234567");
});
it("возвращает null для неполного номера", () => {
expect(normalizePhone("701 123")).toBeNull();
});
});jest-expo находит файлы с суффиксом -test в папке __tests__import { render, screen, userEvent } from "@testing-library/react-native";
import { CallbackForm } from "../components/CallbackForm";
describe("CallbackForm", () => {
it("отправляет нормализованный номер", async () => {
const onSubmit = jest.fn();
const user = userEvent.setup();
await render(<CallbackForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText("Телефон"), "8 701 123 45 67");
await user.press(screen.getByRole("button", { name: "Перезвоните мне" }));
expect(onSubmit).toHaveBeenCalledWith("+77011234567");
expect(screen.queryByRole("alert")).toBeNull();
});
it("показывает ошибку и не отправляет неполный номер", async () => {
const onSubmit = jest.fn();
const user = userEvent.setup();
await render(<CallbackForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText("Телефон"), "701");
await user.press(screen.getByRole("button", { name: "Перезвоните мне" }));
expect(screen.getByRole("alert")).toHaveTextContent("Проверьте номер телефона");
expect(onSubmit).not.toHaveBeenCalled();
});
});userEventОбратите внимание на await перед render. В RNTL 14 render, fireEvent и act стали асинхронными, а для работы библиотеке нужны React 19, React Native 0.78+ и пакет test-renderer (npm 7+ ставит его сам как peer-зависимость). Матчеры вроде toHaveTextContent подключаются автоматически при импорте из @testing-library/react-native. userEvent имитирует настоящее взаимодействие: press проходит через события нажатия, а type — через нажатие на поле, ввод по символу и потерю фокуса.
$ npx jest --verbose
PASS __tests__/phone-test.ts
normalizePhone
√ приводит разные записи к +7XXXXXXXXXX (6 ms)
√ возвращает null для неполного номера
PASS __tests__/callback-form-test.tsx
CallbackForm
√ отправляет нормализованный номер (1017 ms)
√ показывает ошибку и не отправляет неполный номер (245 ms)
Test Suites: 2 passed, 2 total
Tests: 4 passed, 4 totalПроверка, что тесты не «зелёные всегда»: замените в normalizePhone результат для 11 цифр на +${digits} — оба теста с номером через восьмёрку упадут. Если тест не падает на намеренной ошибке, он ничего не проверяет.
Flutter: unit- и widget-тесты на flutter_test
Во Flutter тестовая библиотека входит в SDK: flutter_test уже прописан в dev_dependencies нового проекта. Unit-тест пишется функцией test(), widget-тест — testWidgets(): она создаёт WidgetTester, который строит виджет, вводит текст и нажимает кнопки без устройства. Как создать проект и устроен Dart, разобрано в уроке о старте с Flutter и Dart; здесь — та же форма, что и в разделе про React Native, чтобы сравнить подходы.
/// Нормализует казахстанский номер к виду +7XXXXXXXXXX или возвращает null.
String? normalizePhone(String input) {
final digits = input.replaceAll(RegExp(r'\D'), '');
if (digits.length == 11 &&
(digits.startsWith('7') || digits.startsWith('8'))) {
return '+7${digits.substring(1)}';
}
if (digits.length == 10) {
return '+7$digits';
}
return null;
}import 'package:flutter/material.dart';
import 'phone.dart';
class CallbackForm extends StatefulWidget {
const CallbackForm({super.key, required this.onSubmit});
final ValueChanged<String> onSubmit;
@override
State<CallbackForm> createState() => _CallbackFormState();
}
class _CallbackFormState extends State<CallbackForm> {
final _controller = TextEditingController();
String? _error;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _submit() {
final phone = normalizePhone(_controller.text);
setState(() => _error = phone == null ? 'Проверьте номер телефона' : null);
if (phone != null) widget.onSubmit(phone);
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
controller: _controller,
keyboardType: TextInputType.phone,
decoration: InputDecoration(labelText: 'Телефон', errorText: _error),
),
FilledButton(onPressed: _submit, child: const Text('Перезвоните мне')),
],
);
}
}errorText поляimport 'package:callback_app/phone.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('приводит разные записи к +7XXXXXXXXXX', () {
expect(normalizePhone('8 (701) 123-45-67'), '+77011234567');
expect(normalizePhone('7011234567'), '+77011234567');
});
test('возвращает null для неполного номера', () {
expect(normalizePhone('701 123'), isNull);
});
}test()import 'package:callback_app/callback_form.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
Future<List<String>> pumpForm(WidgetTester tester) async {
final sent = <String>[];
await tester.pumpWidget(
MaterialApp(
home: Scaffold(body: CallbackForm(onSubmit: sent.add)),
),
);
return sent;
}
testWidgets('отправляет нормализованный номер', (tester) async {
final sent = await pumpForm(tester);
await tester.enterText(find.byType(TextField), '8 701 123 45 67');
await tester.tap(find.text('Перезвоните мне'));
await tester.pump();
expect(sent, ['+77011234567']);
expect(find.text('Проверьте номер телефона'), findsNothing);
});
testWidgets('показывает ошибку для неполного номера', (tester) async {
final sent = await pumpForm(tester);
await tester.enterText(find.byType(TextField), '701');
await tester.tap(find.text('Перезвоните мне'));
await tester.pump();
expect(sent, isEmpty);
expect(find.text('Проверьте номер телефона'), findsOneWidget);
});
}MaterialApp и Scaffold, как в настоящем приложенииПосле tap нужен tester.pump(): он планирует кадр и перестраивает виджет, иначе текст ошибки ещё не появится в дереве. Если на экране идёт анимация, используют pumpAndSettle() — он крутит кадры, пока анимации не закончатся. Поиск — через find.text, find.byType или find.byKey, проверка — матчерами findsOneWidget, findsNothing, findsNWidgets.
$ flutter analyze
No issues found!
$ flutter test
00:01 +4: All tests passed!ghcr.io/cirruslabs/flutter:stable (Flutter 3.44): анализ и 4 тестаE2E: Maestro, Detox и integration_test
E2E-тест (end-to-end, «из конца в конец») запускает собранное приложение на эмуляторе, симуляторе или устройстве и проходит сценарий так, как это сделал бы пользователь. Документация React Native советует собирать приложение для такого теста в релизной конфигурации. Это самый медленный и капризный уровень, поэтому E2E покрывают только главные сценарии.
Maestro
- Платформы
- Android, iOS, React Native, Flutter, веб
- Как устроен
- Сценарии на YAML; работает через слой доступности, в приложение ничего не устанавливается
- Когда выбирать
- Нужен один набор сценариев для обеих платформ и быстрый старт; поддерживается в EAS Workflows
Detox
- Платформы
- React Native
- Как устроен
- Тесты на JavaScript; подход gray-box — фреймворк видит внутреннее состояние приложения и дожидается сетевых запросов и анимаций, поэтому тесты предсказуемее
- Когда выбирать
- Команда на React Native хочет писать E2E на том же языке и дожидаться асинхронной работы приложения без ручных пауз
integration_test
- Платформы
- Flutter
- Как устроен
- Пакет из Flutter SDK; тесты пишутся тем же API, что и widget-тесты, но запускаются на устройстве
- Когда выбирать
- Flutter-проект; учтите, что пакет не умеет нажимать системные диалоги разрешений и уведомления — для этого документация Flutter предлагает Patrol
Сценарий Maestro для нашей формы. В React Native свойство testID Maestro видит как id — такой селектор не ломается при смене текста или перевода интерфейса.
appId: kz.example.callback
---
- launchApp:
clearState: true
- tapOn:
id: "phone-input"
- inputText: "701"
- tapOn:
id: "submit-button"
- assertVisible: "Проверьте номер телефона"maestro test .maestro/callback.yaml при запущенном эмуляторе с установленной сборкой; appId — идентификатор вашего приложенияВо Flutter E2E-тест кладут в папку integration_test/, вызывают в начале IntegrationTestWidgetsFlutterBinding.ensureInitialized() и запускают командой flutter test integration_test/app_test.dart при подключённом устройстве. Те же тесты можно прогнать на ферме устройств Firebase Test Lab — порядок для Android и iOS описан в документации Flutter.
Ручное тестирование: матрица устройств и тестовые сборки
Автотесты не увидят, что кнопка ушла под вырез экрана, клавиатура закрыла поле ввода, а системный диалог разрешения на старом Android выглядит иначе. Поэтому перед релизом приложение проходят руками на наборе устройств — матрице. Её собирают из аналитики своей аудитории, а если её ещё нет — из крайних случаев.
Версия ОС
- Что включить
- Минимальная поддерживаемая и актуальная для iOS и Android
- Что ловит
- Разное поведение разрешений, уведомлений, системных диалогов
Размер экрана
- Что включить
- Маленький телефон, большой телефон, планшет (если он поддерживается)
- Что ловит
- Обрезанный текст, кнопки за краем, прокрутку
Производительность
- Что включить
- Бюджетный Android с небольшим объёмом памяти
- Что ловит
- Подтормаживания, выгрузку приложения из памяти
Сеть
- Что включить
- Wi-Fi, медленный мобильный интернет, режим полёта
- Что ловит
- Бесконечные загрузки, потерянные данные форм
Настройки
- Что включить
- Крупный шрифт, тёмная тема, другой язык системы
- Что ловит
- Сломанную вёрстку, нечитаемые цвета
Тестовую сборку раздают через официальные каналы сторов — так тестировщики ставят то же приложение, которое уйдёт в релиз.
TestFlight (App Store Connect)
- До 100 внутренних тестировщиков из команды с ролями в App Store Connect
- До 10 000 внешних тестировщиков по email или публичной ссылке
- Первая сборка для внешних тестировщиков проходит проверку App Review для TestFlight
- Каждый тестировщик может поставить сборку на устройства до 30 штук
Тестирование в Google Play Console
- Внутреннее тестирование: до 100 тестировщиков на приложение, сборка доступна в течение минут
- Закрытое тестирование: списки email или Google Groups
- Открытое тестирование: присоединиться может любой желающий
- Новые личные аккаунты (созданы после 13 ноября 2023) перед выходом в продакшн обязаны провести закрытый тест: минимум 12 тестировщиков, непрерывно 14 дней
Чек-лист перед релизом и упражнения
tsc --noEmit(илиflutter analyze) и линтер проходят без ошибок- Unit- и компонентные тесты зелёные в CI, а не только локально
- E2E-сценарии главных потоков — вход, основное действие, оплата — прошли на собранном приложении
- Сборка прошла по матрице устройств руками: минимальная и актуальная ОС, маленький экран, слабый Android
- Проверены сценарии без сети и с медленной сетью, сворачивание и возврат в приложение
- Системные разрешения: отказ, повторный запрос, переход в настройки
- Тестовая сборка разослана через TestFlight и внутреннее тестирование Google Play, отзывы разобраны
Как проверено2 · пройдено2 · не запускалосьсверено 11 октября 2026 г.
- запущено, работает
RN: lib/phone.ts, components/CallbackForm.tsx, __tests__/phone-test.ts, __tests__/callback-form-test.tsx, package.json (jest preset), tsconfig.json
временный проект create-expo-app (blank-typescript, Expo SDK 57.0.27, RN 0.86.3, React 19.2.3), npx expo install jest-expo jest @types/jest @testing-library/react-native --dev (jest 29.7.0, jest-expo 57.0.5, RNTL 14.1.0, test-renderer 1.3.0 как peer), npx tsc --noEmit OK, npx jest — 2 набора, 4 теста PASS; мутация normalizePhone (+${digits}) → 2 теста FAIL, возврат → PASS
- запущено, работает
Flutter: lib/phone.dart, lib/callback_form.dart, test/phone_test.dart, test/callback_form_test.dart
docker ghcr.io/cirruslabs/flutter:stable (Flutter 3.44.0, Dart 3.12.0), flutter create --empty, flutter pub get, dart format, flutter analyze — No issues, flutter test — 4 теста passed; контейнеры --rm с именами kb-flutter-m11-*
- не запускалось
.maestro/callback.yaml (Maestro flow)
эмулятора/устройства и Maestro CLI на машине нет; синтаксис сверён с документацией Maestro (appId, launchApp clearState, tapOn id, inputText, assertVisible), testID → id по странице Maestro React Native
- не запускалось
Ручное тестирование, TestFlight, Google Play testing
процесс описан по официальным страницам Apple и Google Play Help; на устройствах не проверялось
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.