Тестирование — неотъемлемая часть разработки качественных 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 для проверки полных сценариев использования. Помните о пирамиде тестирования и старайтесь поддерживать высокий уровень покрытия кода тестами.