Розробка 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).







