Разработка Unit-тестов для Flutter-приложения
Flutter поставляется с flutter_test из коробки, но написать хорошие тесты — не то же самое, что просто написать тесты. Типичная проблема Flutter-проектов: тесты есть, но они тестируют только «sunny day scenario», падают при малейшем изменении структуры, или держат внутри себя реальные HTTP-запросы.
Стек для unit-тестов
-
flutter_test— встроенный, основной -
mocktail— предпочтительнееmockitoдля Dart: не требует кодогенерации -
bloc_test— для BLoC/Cubit -
riverpod+ProviderContainer— для Riverpod-based логики -
fake_async— тестирование кода сFuture.delayedиTimer
Тестирование BLoC
BLoC — самая тестируемая архитектура в Flutter. bloc_test делает assert последовательности состояний тривиальным:
blocTest<AuthCubit, AuthState>(
'emits [loading, authenticated] when login succeeds',
build: () {
when(() => mockAuthRepo.login(any(), any()))
.thenAnswer((_) async => User(id: '1', name: 'Test'));
return AuthCubit(authRepository: mockAuthRepo);
},
act: (cubit) => cubit.login('[email protected]', 'password'),
expect: () => [
const AuthState.loading(),
AuthState.authenticated(User(id: '1', name: 'Test')),
],
);
Если в act нужна задержка или асинхронность — await cubit.login(...) внутри act.
Тестирование Riverpod
ProviderContainer позволяет создать изолированное окружение с переопределёнными провайдерами:
test('userProvider returns user on success', () async {
final container = ProviderContainer(
overrides: [
userRepositoryProvider.overrideWithValue(MockUserRepository()),
],
);
addTearDown(container.dispose);
when(() => mockRepo.getUser('1')).thenAnswer((_) async => User(id: '1'));
final user = await container.read(userProvider('1').future);
expect(user.id, '1');
});
Тестирование Use Case и Repository
Use Case — чистая бизнес-логика без Flutter-зависимостей. Тестируется просто:
test('GetOrderUseCase applies discount when user is premium', () async {
when(() => mockOrderRepo.getOrder('order1'))
.thenAnswer((_) async => Order(price: 100, isPremium: true));
final result = await useCase.execute('order1');
expect(result.finalPrice, 85); // 15% скидка
});
Частая ошибка: тестировать Use Case через ViewModel/BLoC, а не напрямую. Это делает тест хрупким и медленным.
fake_async для кода с таймерами
test('debounce search fires after 300ms', () {
fakeAsync((async) {
final controller = SearchController();
controller.query = 'flutter';
async.elapse(Duration(milliseconds: 200));
verifyNever(() => mockRepo.search(any()));
async.elapse(Duration(milliseconds: 100));
verify(() => mockRepo.search('flutter')).called(1);
});
});
fakeAsync позволяет управлять временем без реального sleep — тесты с debounce/throttle запускаются мгновенно.
Типичные ошибки
-
mocktailбезregisterFallbackValueдля кастомных типов —any()не работает с нестандартными классами без регистрации -
Тесты, которые мутируют глобальный state —
SharedPreferencesилиHiveв тестах нужно инициализировать черезSharedPreferences.setMockInitialValues({})перед каждым тестом -
Отсутствие
tearDown—ProviderContainer.dispose()иStreamController.close()забывают, и тесты текут памятью
CI-интеграция
flutter test --coverage → lcov.info → genhtml для HTML-отчёта. В GitHub Actions добавляем шаг с flutter analyze + flutter test на каждый PR. Для фильтрации покрытия (исключаем generated-файлы) — remove_from_coverage пакет или sed-фильтр по lcov.info.
Срок: 3–5 дней в зависимости от объёма проекта и используемой архитектуры (BLoC / Riverpod / GetX).







