Введение в Тестирование Flutter

Flutter предоставляет комплексный фреймворк для тестирования, который позволяет разработчикам писать надежные и поддерживаемые тесты на нескольких уровнях. В отличие от традиционных подходов к мобильному тестированию, многослойная архитектура Flutter (как обсуждается в Mobile Testing in 2025: iOS, Android and Beyond) позволяет тестировать всё — от отдельных функций до полных пользовательских сценариев — с исключительной скоростью и точностью.

Экосистема тестирования Flutter включает три основных типа тестов:

  • Юнит Тесты: Проверяют бизнес-логику, вычисления и преобразования данных
  • Тесты Виджетов: Валидируют UI компоненты и пользовательские взаимодействия
  • Интеграционные Тесты: Тестируют полные рабочие процессы приложения на реальных или виртуальных устройствах

Это руководство охватывает продвинутые техники тестирования, лучшие практики и реальные примеры для создания надежных Flutter приложений.

Настройка Среды Тестирования Flutter

Конфигурация Зависимостей

Добавьте зависимости для тестирования в pubspec.yaml:

dev_dependencies:
  flutter_test:
    sdk: flutter
  mockito: ^5.4.4
  build_runner: ^2.4.6
  integration_test:
    sdk: flutter
  flutter_driver:
    sdk: flutter

Структура Проекта

Организуйте тесты согласно конвенциям Flutter:

project/
├── lib/
├── test/              # Юнит и виджет тесты
│   ├── models/
│   ├── services/
│   └── widgets/
├── integration_test/  # Интеграционные тесты
└── test_driver/       # Драйвер скрипты

Юнит Тестирование в Flutter

Тестирование Бизнес-Логики

Юнит тесты фокусируются на тестировании чистого Dart кода без UI зависимостей. Вот комплексный пример тестирования сервиса корзины покупок:

// lib/services/cart_service.dart
class CartService {
  final List<CartItem> _items = [];

  double get totalPrice => _items.fold(
    0.0,
    (sum, item) => sum + (item.price * item.quantity)
  );

  void addItem(CartItem item) {
    final existingIndex = _items.indexWhere(
      (i) => i.productId == item.productId
    );

    if (existingIndex >= 0) {
      _items[existingIndex].quantity += item.quantity;
    } else {
      _items.add(item);
    }
  }

  void removeItem(String productId) {
    _items.removeWhere((item) => item.productId == productId);
  }

  void clearCart() => _items.clear();
}

// test/services/cart_service_test.dart
import 'package:flutter_test/flutter_test.dart';

void main() {
  late CartService cartService;

  setUp(() {
    cartService = CartService();
  });

  group('CartService', () {
    test('должен добавить новый товар в пустую корзину', () {
      final item = CartItem(
        productId: '1',
        name: 'Товар',
        price: 10.0,
        quantity: 1
      );

      cartService.addItem(item);

      expect(cartService.totalPrice, 10.0);
    });

    test('должен объединять количества для дублирующихся товаров', () {
      final item = CartItem(
        productId: '1',
        name: 'Товар',
        price: 10.0,
        quantity: 2
      );

      cartService.addItem(item);
      cartService.addItem(item);

      expect(cartService.totalPrice, 40.0);
    });

    test('должен правильно рассчитывать общую цену', () {
      cartService.addItem(CartItem(
        productId: '1',
        price: 10.0,
        quantity: 2
      ));
      cartService.addItem(CartItem(
        productId: '2',
        price: 15.0,
        quantity: 1
      ));

      expect(cartService.totalPrice, 35.0);
    });
  });
}

Моки Зависимостей с Mockito

Для тестирования компонентов с внешними зависимостями используйте Mockito для создания mock объектов:

// lib/repositories/user_repository.dart
abstract class UserRepository {
  Future<User> fetchUser(String id);
  Future<void> updateUser(User user);
}

// lib/services/user_service.dart
class UserService {
  final UserRepository repository;

  UserService(this.repository);

  Future<User> getUser(String id) async {
    final user = await repository.fetchUser(id);
    if (user.name.isEmpty) {
      throw ValidationException('Имя пользователя не может быть пустым');
    }
    return user;
  }
}

// test/services/user_service_test.dart
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:flutter_test/flutter_test.dart';

@GenerateMocks([UserRepository])
void main() {
  late UserService service;
  late MockUserRepository mockRepository;

  setUp(() {
    mockRepository = MockUserRepository();
    service = UserService(mockRepository);
  });

  test('должен вернуть пользователя когда валидный', () async {
    final user = User(id: '1', name: 'Иван Иванов');

    when(mockRepository.fetchUser('1'))
        .thenAnswer((_) async => user);

    final result = await service.getUser('1');

    expect(result.name, 'Иван Иванов');
    verify(mockRepository.fetchUser('1')).called(1);
  });

  test('должен выбросить исключение для невалидного пользователя', () async {
    final user = User(id: '1', name: '');

    when(mockRepository.fetchUser('1'))
        .thenAnswer((_) async => user);

    expect(
      () => service.getUser('1'),
      throwsA(isA<ValidationException>())
    );
  });
}

Запустите flutter pub run build_runner build для генерации mock классов.

Тестирование Виджетов

Тестирование UI Компонентов

Тесты виджетов проверяют рендеринг UI и взаимодействия пользователя. Они выполняются быстрее интеграционных тестов, обеспечивая уверенность в поведении UI:

// lib/widgets/counter_widget.dart
class CounterWidget extends StatefulWidget {
  @override
  _CounterWidgetState createState() => _CounterWidgetState();
}

class _CounterWidgetState extends State<CounterWidget> {
  int _counter = 0;

  void _increment() => setState(() => _counter++);
  void _decrement() => setState(() => _counter--);

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('Счёт: $_counter', key: Key('counter_text')),
        ElevatedButton(
          key: Key('increment_button'),
          onPressed: _increment,
          child: Text('Увеличить'),
        ),
        ElevatedButton(
          key: Key('decrement_button'),
          onPressed: _decrement,
          child: Text('Уменьшить'),
        ),
      ],
    );
  }
}

// test/widgets/counter_widget_test.dart
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('CounterWidget увеличивает и уменьшает',
    (WidgetTester tester) async {

    await tester.pumpWidget(
      MaterialApp(home: Scaffold(body: CounterWidget()))
    );

    // Проверить начальное состояние
    expect(find.text('Счёт: 0'), findsOneWidget);

    // Нажать кнопку увеличения
    await tester.tap(find.byKey(Key('increment_button')));
    await tester.pump();

    expect(find.text('Счёт: 1'), findsOneWidget);

    // Нажать кнопку уменьшения
    await tester.tap(find.byKey(Key('decrement_button')));
    await tester.pump();

    expect(find.text('Счёт: 0'), findsOneWidget);
  });

  testWidgets('CounterWidget обрабатывает множественные нажатия',
    (WidgetTester tester) async {

    await tester.pumpWidget(
      MaterialApp(home: Scaffold(body: CounterWidget()))
    );

    // Множественные увеличения
    for (int i = 0; i < 5; i++) {
      await tester.tap(find.byKey(Key('increment_button')));
      await tester.pump();
    }

    expect(find.text('Счёт: 5'), findsOneWidget);
  });
}

Тестирование Асинхронных Виджетов

При тестировании виджетов с асинхронными операциями используйте pumpAndSettle() для ожидания анимаций и async операций:

// lib/widgets/user_profile.dart
class UserProfile extends StatelessWidget {
  final Future<User> userFuture;

  UserProfile({required this.userFuture});

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<User>(
      future: userFuture,
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return CircularProgressIndicator(
            key: Key('loading_indicator')
          );
        }

        if (snapshot.hasError) {
          return Text('Ошибка: ${snapshot.error}',
            key: Key('error_text')
          );
        }

        return Text('Пользователь: ${snapshot.data!.name}',
          key: Key('user_name')
        );
      },
    );
  }
}

// test/widgets/user_profile_test.dart
testWidgets('UserProfile показывает загрузку затем данные',
  (WidgetTester tester) async {

  final userFuture = Future.delayed(
    Duration(milliseconds: 100),
    () => User(name: 'Иван')
  );

  await tester.pumpWidget(
    MaterialApp(home: UserProfile(userFuture: userFuture))
  );

  // Проверить состояние загрузки
  expect(find.byKey(Key('loading_indicator')), findsOneWidget);

  // Подождать async операцию
  await tester.pumpAndSettle();

  // Проверить загруженные данные
  expect(find.text('Пользователь: Иван'), findsOneWidget);
  expect(find.byKey(Key('loading_indicator')), findsNothing);
});

Интеграционное Тестирование

Тестирование Полных Процессов Приложения

Интеграционные тесты проверяют полные пользовательские сценарии на реальных или виртуальных устройствах. Подобно подходам к кроссплатформенному мобильному тестированию, фреймворк интеграционного тестирования Flutter позволяет уверенно валидировать end-to-end процессы:

// integration_test/app_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:my_app/main.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('Интеграционный Тест Процесса Входа', () {
    testWidgets('полный процесс входа',
      (WidgetTester tester) async {

      app.main();
      await tester.pumpAndSettle();

      // Перейти к входу
      await tester.tap(find.text('Войти'));
      await tester.pumpAndSettle();

      // Ввести учетные данные
      await tester.enterText(
        find.byKey(Key('email_field')),
        'test@example.com'
      );
      await tester.enterText(
        find.byKey(Key('password_field')),
        'password123'
      );

      // Отправить вход
      await tester.tap(find.byKey(Key('login_button')));
      await tester.pumpAndSettle(Duration(seconds: 5));

      // Проверить переход на главную
      expect(find.text('Добро пожаловать'), findsOneWidget);
    });

    testWidgets('показывает ошибку для неверных учетных данных',
      (WidgetTester tester) async {

      app.main();
      await tester.pumpAndSettle();

      await tester.tap(find.text('Войти'));
      await tester.pumpAndSettle();

      await tester.enterText(
        find.byKey(Key('email_field')),
        'invalid@example.com'
      );
      await tester.enterText(
        find.byKey(Key('password_field')),
        'wrong'
      );

      await tester.tap(find.byKey(Key('login_button')));
      await tester.pumpAndSettle(Duration(seconds: 5));

      expect(find.text('Неверные учетные данные'), findsOneWidget);
    });
  });
}

Запуск Интеграционных Тестов

Выполняйте интеграционные тесты на устройствах или эмуляторах:

# Запустить на подключенном устройстве
flutter test integration_test/app_test.dart

# Запустить на конкретном устройстве
flutter test integration_test/app_test.dart -d emulator-5554

# Запустить с драйвером для продвинутых сценариев
flutter drive \
  --driver=test_driver/integration_test.dart \
  --target=integration_test/app_test.dart

Лучшие Практики и Стратегии Тестирования

Организация Тестов

Тип ТестаКоличествоСкоростьФокус Покрытия
Юнит Тесты70%БыстрыеБизнес-логика, утилиты
Тесты Виджетов20%СредниеUI компоненты, взаимодействия
Интеграционные Тесты10%МедленныеКритические пользовательские процессы

Golden Тестирование для UI Регрессии

Захватывайте и сравнивайте скриншоты виджетов:

testWidgets('Кнопка совпадает с golden файлом',
  (WidgetTester tester) async {

  await tester.pumpWidget(
    MaterialApp(home: CustomButton(text: 'Отправить'))
  );

  await expectLater(
    find.byType(CustomButton),
    matchesGoldenFile('goldens/custom_button.png')
  );
});

Обновляйте golden файлы при изменении UI:

flutter test --update-goldens

Тестирование Производительности

Мониторьте производительность построения виджетов:

testWidgets('ListView работает эффективно',
  (WidgetTester tester) async {

  await tester.pumpWidget(
    MaterialApp(
      home: ListView.builder(
        itemCount: 1000,
        itemBuilder: (context, index) => ListTile(
          title: Text('Элемент $index')
        ),
      ),
    ),
  );

  await tester.pumpAndSettle();

  // Измерить производительность прокрутки
  await tester.fling(
    find.byType(ListView),
    Offset(0, -500),
    1000
  );

  await tester.pumpAndSettle();

  // Проверить отсутствие пропуска кадров
  expect(tester.binding.hasScheduledFrame, isFalse);
});

Продвинутые Техники Тестирования

Тестирование State Management

Для тестирования управления состоянием на основе Provider, Flutter предоставляет надежные инструменты аналогичные тем, которые вы найдете в React Native Testing Library:

testWidgets('Counter provider обновляет UI',
  (WidgetTester tester) async {

  await tester.pumpWidget(
    ChangeNotifierProvider(
      create: (_) => CounterProvider(),
      child: MaterialApp(home: CounterScreen()),
    ),
  );

  expect(find.text('0'), findsOneWidget);

  await tester.tap(find.byIcon(Icons.add));
  await tester.pump();

  expect(find.text('1'), findsOneWidget);
});

Тестирование Навигации

Проверяйте поведение роутинга:

testWidgets('Навигация на экран деталей',
  (WidgetTester tester) async {

  await tester.pumpWidget(
    MaterialApp(
      routes: {
        '/': (context) => HomeScreen(),
        '/detail': (context) => DetailScreen(),
      },
    ),
  );

  await tester.tap(find.text('Посмотреть Детали'));
  await tester.pumpAndSettle();

  expect(find.byType(DetailScreen), findsOneWidget);
});

Настройка Непрерывной Интеграции

Конфигурация GitHub Actions

name: Flutter Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.16.0'

      - name: Установить зависимости
        run: flutter pub get

      - name: Запустить юнит тесты
        run: flutter test --coverage

      - name: Загрузить покрытие
        uses: codecov/codecov-action@v3
        with:
          file: coverage/lcov.info

Заключение

Фреймворк тестирования Flutter предоставляет мощные инструменты для обеспечения качества кода на всех уровнях вашего приложения. Комбинируя юнит тесты для бизнес-логики, тесты виджетов для UI компонентов и интеграционные тесты для критических процессов, вы создаете надежную страховочную сеть, которая обеспечивает уверенное рефакторинг и быструю разработку новых функций.

Ключевые Выводы:

  • Следуйте пирамиде тестирования: 70% юнит, 20% виджеты, 10% интеграция
  • Используйте mockito для изоляции зависимостей в юнит тестах
  • Применяйте WidgetTester для комплексной валидации UI
  • Внедряйте golden тесты для обнаружения визуальных регрессий
  • Автоматизируйте тестирование в CI/CD пайплайнах

Начните с высокоценных тестов, покрывающих критическую бизнес-логику и пользовательские процессы, затем расширяйте покрытие итеративно по мере роста вашего приложения.