Как делать11 октября 2026 г.8 мин чтения
Flutter + DartШаг 5 из 6

Тестирование мобильных приложений

Коротко

Мобильное приложение проверяют на трёх уровнях: 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 г.

Содержание
  1. 01Чем тестирование мобильного приложения отличается от веба
  2. 02Уровни тестов: что где запускается
  3. 03React Native и Expo: Jest и React Native Testing Library
  4. 04Flutter: unit- и widget-тесты на flutter_test
  5. 05E2E: Maestro, Detox и integration_test
  6. 06Ручное тестирование: матрица устройств и тестовые сборки
  7. 07Чек-лист перед релизом и упражнения

Чем тестирование мобильного приложения отличается от веба

Сайт открывается в браузере, который обновляется сам, и исправление доезжает до пользователя после деплоя. Мобильное приложение живёт иначе: его собирают под две платформы, проводят через модерацию сторов, а пользователь может месяцами не обновляться. Ошибка, попавшая в релиз, стоит дороже — исправление снова проходит сборку и ревью.

  • Устройства: разные размеры экранов, вырезы, плотность пикселей, объём памяти, старые и новые процессоры.
  • Версии ОС: 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 и новее — удалите его, если он остался в проекте.

bash
npx create-expo-app@latest app --template blank-typescript
cd app
npx expo install jest-expo jest @types/jest @testing-library/react-native --dev
Новый проект Expo и тестовые зависимости. npx expo install подбирает версии, совместимые с SDK
package.json
{
  "scripts": {
    "test": "jest --watchAll",
    "test:ci": "jest"
  },
  "jest": {
    "preset": "jest-expo"
  }
}
Фрагмент: пресет и скрипты. test — режим наблюдения, test:ci — один прогон для CI
tsconfig.json
{
  "extends": "expo/tsconfig.base",
  "compilerOptions": {
    "strict": true,
    "types": [
      "jest"
    ]
  }
}
Типы Jest для TypeScript — по рекомендации документации Expo

Пример — форма «перезвоните мне», типичная для приложений сервисного бизнеса. Логику нормализации номера выносим в отдельную функцию: её проверяет быстрый unit-тест, а форма отвечает только за интерфейс.

lib/phone.ts
// Нормализует казахстанский номер к виду +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;
}
Чистая функция — идеальный кандидат для unit-теста
components/CallbackForm.tsx
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 пригодится для E2E
__tests__/phone-test.ts
import { 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();
  });
});
Unit-тест. Пресет jest-expo находит файлы с суффиксом -test в папке __tests__
__tests__/callback-form-test.tsx
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 — через нажатие на поле, ввод по символу и потерю фокуса.

terminal
$ 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
Реальный прогон во временном проекте Expo SDK 57

Проверка, что тесты не «зелёные всегда»: замените в normalizePhone результат для 11 цифр на +${digits} — оба теста с номером через восьмёрку упадут. Если тест не падает на намеренной ошибке, он ничего не проверяет.

Flutter: unit- и widget-тесты на flutter_test

Во Flutter тестовая библиотека входит в SDK: flutter_test уже прописан в dev_dependencies нового проекта. Unit-тест пишется функцией test(), widget-тест — testWidgets(): она создаёт WidgetTester, который строит виджет, вводит текст и нажимает кнопки без устройства. Как создать проект и устроен Dart, разобрано в уроке о старте с Flutter и Dart; здесь — та же форма, что и в разделе про React Native, чтобы сравнить подходы.

lib/phone.dart
/// Нормализует казахстанский номер к виду +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;
}
Та же логика нормализации на Dart
lib/callback_form.dart
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 поля
test/phone_test.dart
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);
  });
}
Unit-тест на test()
test/callback_form_test.dart
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);
  });
}
Widget-тест: виджет оборачиваем в MaterialApp и Scaffold, как в настоящем приложении

После tap нужен tester.pump(): он планирует кадр и перестраивает виджет, иначе текст ошибки ещё не появится в дереве. Если на экране идёт анимация, используют pumpAndSettle() — он крутит кадры, пока анимации не закончатся. Поиск — через find.text, find.byType или find.byKey, проверка — матчерами findsOneWidget, findsNothing, findsNWidgets.

terminal
$ 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 — такой селектор не ломается при смене текста или перевода интерфейса.

.maestro/callback.yaml
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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

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

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.