Рубрики
Flutter

Тестирование во Flutter: Полное руководство 2025

Полное руководство по тестированию Flutter приложений в 2025: unit, widget, integration тесты с mocking и best practices.

Тестирование — неотъемлемая часть разработки качественных Flutter приложений. В этом руководстве мы разберём все типы тестов, научимся писать надёжные тесты и покроем лучшие практики тестирования в Flutter.

Виды тестов во Flutter

Flutter поддерживает три основных типа тестов, каждый из которых решает свои задачи:

  • Unit тесты — тестируют изолированные функции и классы без зависимостей от Flutter фреймворка
  • Widget тесты — тестируют отдельные UI компоненты в изолированном окружении
  • Integration тесты — тестируют всё приложение целиком, имитируя действия пользователя

Пирамида тестирования

        Integration
       /            \
      /              \
     /    Widget      \
    /  (много тестов)  \
   /____________________\
  Unit    (много тестов, быстрые)

Идеальный баланс: больше всего unit тестов, меньше widget, ещё меньше integration. Unit тесты быстрые и дешёвые в поддержке, integration — медленные и дорогие.

Unit тесты

Unit тесты проверяют работу отдельных функций и классов в изоляции. Они не зависят от Flutter фреймворка и выполняются очень быстро.

Базовый пример

Начнём с простого примера. Допустим, у нас есть класс для валидации email:

// lib/models/user.dart
class User {
  final String name;
  final String email;

  User({required this.name, required this.email});

  bool get isValidEmail {
    return email.contains('@') && email.contains('.');
  }
}

Напишем unit тест для этого класса:

// test/models/user_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/models/user.dart';

void main() {
  group('User', () {
    test('isValidEmail returns true for valid email', () {
      final user = User(name: 'John', email: 'john@example.com');

      expect(user.isValidEmail, isTrue);
    });

    test('isValidEmail returns false for invalid email', () {
      final user = User(name: 'John', email: 'invalid-email');

      expect(user.isValidEmail, isFalse);
    });

    test('isValidEmail returns false when email missing @', () {
      final user = User(name: 'John', email: 'example.com');

      expect(user.isValidEmail, isFalse);
    });
  });
}

Запуск тестов

# Запустить все тесты
flutter test

# Запустить конкретный файл
flutter test test/models/user_test.dart

# Запустить с покрытием кода
flutter test --coverage

Использование Matchers

Flutter Test предоставляет мощные matchers для проверок:

import 'package:flutter_test/flutter_test.dart';

void main() {
  test('Matchers examples', () {
    final number = 42;
    final text = 'Hello World';
    final items = ['apple', 'banana', 'orange'];

    // Equality matchers
    expect(number, equals(42));
    expect(number, isNotNull);
    expect(number, isPositive);
    expect(number, greaterThan(40));
    expect(number, inInclusiveRange(40, 50));

    // String matchers
    expect(text, contains('World'));
    expect(text, startsWith('Hello'));
    expect(text, endsWith('World'));
    expect(text, matches(r'^Hello.*'));

    // Collection matchers
    expect(items, contains('banana'));
    expect(items, hasLength(3));
    expect(items, isNotEmpty);
    expect(items, allOf(hasLength(greaterThan(2)), contains('apple')));
  });
}

Тестирование асинхронного кода

Для асинхронного кода используйте expectLater и await:

test('Async function test', () async {
  final result = await fetchData();

  expect(result, isNotNull);
  expect(result.status, equals('success'));
});

// Тестирование Stream
test('Stream emits values', () async {
  final stream = countStream(3);

  await expectLater(
    stream,
    emitsInOrder([1, 2, 3, emitsDone]),
  );
});

Группировка тестов

Используйте group для логической организации тестов:

void main() {
  group('AuthenticationService', () {
    late AuthService authService;

    setUp(() {
      // Выполняется перед каждым тестом
      authService = AuthService();
    });

    tearDown(() {
      // Выполняется после каждого теста
      authService.dispose();
    });

    group('login', () {
      test('succeeds with valid credentials', () async {
        final result = await authService.login('user@example.com', 'password');

        expect(result.isSuccess, isTrue);
        expect(result.user, isNotNull);
      });

      test('fails with invalid credentials', () async {
        final result = await authService.login('user@example.com', 'wrong');

        expect(result.isSuccess, isFalse);
        expect(result.error, equals('Invalid credentials'));
      });
    });

    group('register', () {
      test('creates new user', () async {
        final result = await authService.register('new@example.com', 'password');

        expect(result.isSuccess, isTrue);
      });

      test('fails with duplicate email', () async {
        await authService.register('user@example.com', 'password');

        final result = await authService.register('user@example.com', 'password');

        expect(result.isSuccess, isFalse);
      });
    });
  });
}

Mocking с Mockito

При тестировании кода с зависимостями используем mock объекты. Mockito — популярный пакет для создания mock объектов во Flutter.

Установка

dev_dependencies:
  mockito: ^5.4.0
  build_runner: ^2.4.0

Создание Mock классов

import 'package:mockito/mockito.dart';

// Mock класс
class MockAuthService extends Mock implements AuthService {}

class MockHttpClient extends Mock implements http.Client {}

class MockNavigatorObserver extends Mock implements NavigatorObserver {}

Использование Mock в тестах

import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';

@GenerateMocks([http.Client])
import 'auth_test.mocks.dart';

void main() {
  group('LoginViewModel', () {
    late LoginViewModel viewModel;
    late MockClient mockClient;

    setUp(() {
      mockClient = MockClient();
      viewModel = LoginViewModel(mockClient);
    });

    test('login succeeds with valid credentials', () async {
      // Arrange: настраиваем mock
      when(mockClient.post(any, body: anyNamed('body')))
          .thenAnswer((_) async => http.Response('{"token": "abc123"}', 200));

      // Act: выполняем действие
      final result = await viewModel.login('user@example.com', 'password');

      // Assert: проверяем результат
      expect(result.isSuccess, isTrue);

      // Проверяем, что mock был вызван правильно
      verify(mockClient.post(
        argThat(equals('/api/login')),
        body: argThat(contains('user@example.com')),
      )).called(1);
    });

    test('login fails with 401', () async {
      when(mockClient.post(any, body: anyNamed('body')))
          .thenAnswer((_) async => http.Response('Unauthorized', 401));

      final result = await viewModel.login('user@example.com', 'password');

      expect(result.isSuccess, isFalse);
      expect(result.error, equals('Unauthorized'));
    });
  });
}

Verify взаимодействий

verify проверяет, что методы были вызваны правильно:

test('calls logout when error occurs', () async {
  final mockAuth = MockAuthService();
  final viewModel = ProfileViewModel(mockAuth);

  when(mockAuth.getUser()).thenThrow(Exception('Network error'));

  await viewModel.loadProfile();

  verify(mockAuth.logout()).called(1);
});

// Проверка, что метод не был вызван
verifyNever(mockAuth.deleteAccount());

// Проверка количества вызовов
verify(mockAuth.refreshToken()).called(3);

Widget тесты

Widget тесты проверяют правильность рендеринга UI компонентов. Они запускаются в тестовом окружении Flutter, но быстрее integration тестов.

Базовый widget тест

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('MyWidget displays text', (tester) async {
    // Загружаем виджет
    await tester.pumpWidget(MyWidget());

    // Проверяем наличие текста
    expect(find.text('Hello World'), findsOneWidget);
  });
}

Тестирование взаимодействий

testWidgets('Button increments counter', (tester) async {
  await tester.pumpWidget(MyApp());

  // Находим текст с начальным значением
  expect(find.text('0'), findsOneWidget);

  // Находим кнопку и нажимаем
  await tester.tap(find.byType(FloatingActionButton));

  // Обновляем виджет после изменения состояния
  await tester.pump();

  // Проверяем новое значение
  expect(find.text('1'), findsOneWidget);
});

Работа с TextField

testWidgets('TextField input and validation', (tester) async {
  await tester.pumpWidget(MyForm());

  final textField = find.byType(TextField);

  // Вводим текст
  await tester.enterText(textField, 'invalid-email');

  // Тап на кнопку валидации
  await tester.tap(find.byType(ElevatedButton));
  await tester.pump();

  // Проверяем сообщение об ошибке
  expect(find.text('Invalid email'), findsOneWidget);

  // Вводим корректный email
  await tester.enterText(textField, 'valid@example.com');
  await tester.tap(find.byType(ElevatedButton));
  await tester.pump();

  // Проверяем успешную валидацию
  expect(find.text('Success'), findsOneWidget);
});

Поиск виджетов

Flutter Test предоставляет множество способов поиска виджетов:

testWidgets('Finding widgets examples', (tester) async {
  await tester.pumpWidget(MyWidget());

  // По тексту
  expect(find.text('Submit'), findsOneWidget);

  // По типу
  expect(find.byType(ElevatedButton), findsOneWidget);

  // По ключу
  expect(find.byKey(Key('submit-button')), findsOneWidget);

  // По иконке
  expect(find.byIcon(Icons.send), findsOneWidget);

  // По виджету с определённым свойством
  expect(find.byWidgetPredicate((widget) =>
      widget is Text && widget.data?.startsWith('Hello') == true),
      findsOneWidget);
});

Matchers для поиска

  • findsOneWidget — найден ровно один виджет
  • findsNothing — виджет не найден
  • findsWidgets — найдено несколько виджетов
  • findsNWidgets(n) — найдено ровно n виджетов

Тестирование ScrollView

testWidgets('ListView scrolling', (tester) async {
  final items = List.generate(100, (i) => 'Item $i');

  await tester.pumpWidget(
    MaterialApp(
      home: Scaffold(
        body: ListView.builder(
          itemCount: items.length,
          itemBuilder: (context, index) => ListTile(title: Text(items[index])),
        ),
      ),
    ),
  );

  // Проверяем видимый элемент
  expect(find.text('Item 0'), findsOneWidget);

  // Скроллим вниз
  await tester.drag(find.byType(ListView), Offset(0, -500));
  await tester.pump();

  // Теперь видим другие элементы
  expect(find.text('Item 10'), findsOneWidget);
  expect(find.text('Item 0'), findsNothing);
});

Тестирование с Provider

testWidgets('Counter with Provider', (tester) async {
  await tester.pumpWidget(
    Provider<Counter>(
      create: (_) => Counter(),
      child: const MaterialApp(home: CounterPage()),
    ),
  );

  // Начальное состояние
  expect(find.text('0'), findsOneWidget);

  // Увеличиваем счётчик
  await tester.tap(find.byIcon(Icons.add));
  await tester.pump();

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

  // Проверяем через Provider
  final context = tester.element(find.byType(CounterPage));
  final counter = Provider.of<Counter>(context, listen: false);
  expect(counter.value, equals(1));
});

Integration тесты

Integration тесты проверяют работу всего приложения от начала до конца. Они запускаются на реальном устройстве или эмуляторе.

Настройка

dev_dependencies:
  integration_test:
    sdk: flutter
  flutter_test:
    sdk: flutter

Базовый integration тест

import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('Full login flow', (tester) async {
    await tester.pumpWidget(MyApp());

    // Вводим email
    await tester.enterText(
      find.byKey(Key('email-field')),
      'user@example.com',
    );

    // Вводим пароль
    await tester.enterText(
      find.byKey(Key('password-field')),
      'password123',
    );

    // Нажимаем кнопку входа
    await tester.tap(find.byKey(Key('login-button')));
    await tester.pumpAndSettle();

    // Проверяем, что перешли на экран профиля
    expect(find.text('Welcome'), findsOneWidget);
    expect(find.byType(ProfileScreen), findsOneWidget);
  });
}

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

testWidgets('Complete purchase flow', (tester) async {
  await tester.pumpWidget(MyApp());

  // 1. Логин
  await _login(tester, 'user@example.com', 'password');

  // 2. Выбор товара
  await tester.tap(find.text('Products'));
  await tester.pumpAndSettle();
  await tester.tap(find.text('Product 1'));
  await tester.pumpAndSettle();

  // 3. Добавление в корзину
  await tester.tap(find.text('Add to Cart'));
  await tester.pumpAndSettle();

  // 4. Переход в корзину
  await tester.tap(find.text('Cart'));
  await tester.pumpAndSettle();

  // 5. Checkout
  await tester.tap(find.text('Checkout'));
  await tester.pumpAndSettle();

  // 6. Заполнение формы оплаты
  await tester.enterText(
    find.byKey(Key('card-number')),
    '4111111111111111',
  );
  await tester.tap(find.text('Pay'));
  await tester.pumpAndSettle();

  // Проверяем успешную покупку
  expect(find.text('Order Confirmed'), findsOneWidget);
});

Future<void> _login(WidgetTester tester, String email, String password) async {
  await tester.enterText(find.byKey(Key('email')), email);
  await tester.enterText(find.byKey(Key('password')), password);
  await tester.tap(find.text('Login'));
  await tester.pumpAndSettle();
}

Golden тесты

Golden тесты сравнивают рендеринг виджета с эталонным изображением.

Создание golden теста

testWidgets('MyWidget golden test', (tester) async {
  await tester.pumpWidget(MyWidget());

  await expectLater(
    find.byType(MyWidget),
    matchesGoldenFile('goldens/my_widget.png'),
  );
});

Обновление golden файлов

# Создать/обновить golden файлы
flutter test --update-goldens

# Запустить тест с golden
flutter test

Golden тесты для разных тем

testWidgets('MyWidget in dark mode', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      theme: ThemeData.dark(),
      home: MyWidget(),
    ),
  );

  await expectLater(
    find.byType(MyWidget),
    matchesGoldenFile('goldens/my_widget_dark.png'),
  );
});

Best Practices

1. Organize тесты правильно

test/
├── unit/              # Unit тесты
│   ├── models/
│   ├── services/
│   └── utils/
├── widgets/           # Widget тесты
│   ├── common/
│   ├── screens/
│   └── dialogs/
└── integration/       # Integration тесты
    └── flows/

2. Используйте AAA паттерн

Arrange-Act-Assert делает тесты читаемыми:

test('User can login', () async {
  // Arrange: настраиваем тестовые данные
  final viewModel = LoginViewModel(mockApi);
  when(mockApi.login(any, any)).thenAnswer((_) async => User(id: '1'));

  // Act: выполняем тестируемое действие
  await viewModel.login('user@example.com', 'password');

  // Assert: проверяем результат
  expect(viewModel.user, isNotNull);
  expect(viewModel.user.id, equals('1'));
});

3. Тестируйте edge cases

Не забывайте тестировать граничные случаи:

test('handles empty list', () {
  final result = processItems([]);
  expect(result, isEmpty);
});

test('handles null input', () {
  final result = processInput(null);
  expect(result, isNull);
});

test('handles maximum value', () {
  final counter = Counter(max: 100);
  counter.increment();
  expect(counter.value, equals(100));
});

4. Держите тесты независимыми

setUp(() {
  // Каждый тест начинается с чистого состояния
  service = AuthService();
});

tearDown(() {
  // Очищаем ресурсы после каждого теста
  service.dispose();
});

5. Используйте описательные имена тестов

// Плохо
test('test1', () {});

// Хорошо
test('login succeeds with valid credentials', () {});
test('login fails when user does not exist', () {});
test('login fails with wrong password', () {});

Покрытие кода

Измеряйте покрытие кода тестами:

# Запустить тесты с покрытием
flutter test --coverage

# Генерировать HTML отчёт
genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.html

Заключение

Тестирование Flutter приложений в 2025 — это обязательная практика для создания качественного ПО. Используйте все три типа тестов: unit для быстрой проверки логики, widget для UI компонентов, и integration для проверки полных сценариев использования. Помните о пирамиде тестирования и старайтесь поддерживать высокий уровень покрытия кода тестами.