Add comprehensive Flutter cross-platform development guide covering: - Widget patterns and const optimization - Riverpod/Bloc state management - GoRouter navigation - Performance optimization - Testing strategies - Platform-specific implementations Made-with: Cursor
365 lines
8.4 KiB
Markdown
365 lines
8.4 KiB
Markdown
# Testing Strategies
|
|
|
|
Flutter testing guide covering widget tests, unit tests, integration tests, and mocking patterns.
|
|
|
|
## Test Types
|
|
|
|
| Type | Purpose | Speed | Scope |
|
|
|------|---------|-------|-------|
|
|
| Unit tests | Business logic, utilities | Fast | Single function/class |
|
|
| Widget tests | UI components | Medium | Single widget |
|
|
| Integration tests | Full user flows | Slow | Multiple screens |
|
|
|
|
## Widget Tests
|
|
|
|
### Basic Widget Test
|
|
|
|
```dart
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
void main() {
|
|
testWidgets('Counter increments when button tapped', (tester) async {
|
|
await tester.pumpWidget(const MaterialApp(home: CounterScreen()));
|
|
|
|
// Verify initial state
|
|
expect(find.text('0'), findsOneWidget);
|
|
expect(find.text('1'), findsNothing);
|
|
|
|
// Tap the increment button
|
|
await tester.tap(find.byIcon(Icons.add));
|
|
await tester.pump();
|
|
|
|
// Verify state changed
|
|
expect(find.text('0'), findsNothing);
|
|
expect(find.text('1'), findsOneWidget);
|
|
});
|
|
}
|
|
```
|
|
|
|
### Testing with Riverpod
|
|
|
|
```dart
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
void main() {
|
|
testWidgets('displays user name from provider', (tester) async {
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
userProvider.overrideWithValue(
|
|
AsyncValue.data(User(name: 'Test User')),
|
|
),
|
|
],
|
|
child: const MaterialApp(home: UserScreen()),
|
|
),
|
|
);
|
|
|
|
expect(find.text('Test User'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('shows loading indicator', (tester) async {
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
userProvider.overrideWithValue(const AsyncValue.loading()),
|
|
],
|
|
child: const MaterialApp(home: UserScreen()),
|
|
),
|
|
);
|
|
|
|
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('shows error message', (tester) async {
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
userProvider.overrideWithValue(
|
|
AsyncValue.error('Network error', StackTrace.current),
|
|
),
|
|
],
|
|
child: const MaterialApp(home: UserScreen()),
|
|
),
|
|
);
|
|
|
|
expect(find.text('Network error'), findsOneWidget);
|
|
});
|
|
}
|
|
```
|
|
|
|
### Testing with Bloc
|
|
|
|
```dart
|
|
import 'package:bloc_test/bloc_test.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:mocktail/mocktail.dart';
|
|
|
|
class MockCounterBloc extends MockBloc<CounterEvent, CounterState>
|
|
implements CounterBloc {}
|
|
|
|
void main() {
|
|
late MockCounterBloc mockBloc;
|
|
|
|
setUp(() {
|
|
mockBloc = MockCounterBloc();
|
|
});
|
|
|
|
testWidgets('displays current count', (tester) async {
|
|
when(() => mockBloc.state).thenReturn(const CounterState(value: 42));
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: BlocProvider<CounterBloc>.value(
|
|
value: mockBloc,
|
|
child: const CounterScreen(),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(find.text('42'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('calls increment on button tap', (tester) async {
|
|
when(() => mockBloc.state).thenReturn(const CounterState(value: 0));
|
|
|
|
await tester.pumpWidget(
|
|
MaterialApp(
|
|
home: BlocProvider<CounterBloc>.value(
|
|
value: mockBloc,
|
|
child: const CounterScreen(),
|
|
),
|
|
),
|
|
);
|
|
|
|
await tester.tap(find.byIcon(Icons.add));
|
|
|
|
verify(() => mockBloc.add(CounterIncremented())).called(1);
|
|
});
|
|
}
|
|
```
|
|
|
|
## Bloc Tests
|
|
|
|
```dart
|
|
import 'package:bloc_test/bloc_test.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:mocktail/mocktail.dart';
|
|
|
|
class MockUserRepository extends Mock implements UserRepository {}
|
|
|
|
void main() {
|
|
late MockUserRepository mockRepository;
|
|
|
|
setUp(() {
|
|
mockRepository = MockUserRepository();
|
|
});
|
|
|
|
group('UserBloc', () {
|
|
blocTest<UserBloc, UserState>(
|
|
'emits loading then success when user loaded',
|
|
setUp: () {
|
|
when(() => mockRepository.getUser())
|
|
.thenAnswer((_) async => User(name: 'Test'));
|
|
},
|
|
build: () => UserBloc(repository: mockRepository),
|
|
act: (bloc) => bloc.add(UserRequested()),
|
|
expect: () => [
|
|
const UserState(status: UserStatus.loading),
|
|
UserState(status: UserStatus.success, user: User(name: 'Test')),
|
|
],
|
|
);
|
|
|
|
blocTest<UserBloc, UserState>(
|
|
'emits loading then failure when error occurs',
|
|
setUp: () {
|
|
when(() => mockRepository.getUser())
|
|
.thenThrow(Exception('Network error'));
|
|
},
|
|
build: () => UserBloc(repository: mockRepository),
|
|
act: (bloc) => bloc.add(UserRequested()),
|
|
expect: () => [
|
|
const UserState(status: UserStatus.loading),
|
|
isA<UserState>()
|
|
.having((s) => s.status, 'status', UserStatus.failure),
|
|
],
|
|
);
|
|
});
|
|
}
|
|
```
|
|
|
|
## Unit Tests
|
|
|
|
```dart
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
void main() {
|
|
group('Validator', () {
|
|
test('returns error for empty email', () {
|
|
expect(Validator.email(''), 'Email is required');
|
|
});
|
|
|
|
test('returns error for invalid email', () {
|
|
expect(Validator.email('invalid'), 'Invalid email format');
|
|
});
|
|
|
|
test('returns null for valid email', () {
|
|
expect(Validator.email('test@example.com'), isNull);
|
|
});
|
|
});
|
|
|
|
group('Calculator', () {
|
|
late Calculator calculator;
|
|
|
|
setUp(() {
|
|
calculator = Calculator();
|
|
});
|
|
|
|
test('adds two numbers', () {
|
|
expect(calculator.add(2, 3), 5);
|
|
});
|
|
|
|
test('throws on division by zero', () {
|
|
expect(() => calculator.divide(10, 0), throwsArgumentError);
|
|
});
|
|
});
|
|
}
|
|
```
|
|
|
|
## Mocking with Mocktail
|
|
|
|
```dart
|
|
import 'package:mocktail/mocktail.dart';
|
|
|
|
// Create mock classes
|
|
class MockApiService extends Mock implements ApiService {}
|
|
class MockStorageService extends Mock implements StorageService {}
|
|
|
|
// Register fallback values for complex types
|
|
setUpAll(() {
|
|
registerFallbackValue(User(name: 'fallback'));
|
|
});
|
|
|
|
void main() {
|
|
late MockApiService mockApi;
|
|
|
|
setUp(() {
|
|
mockApi = MockApiService();
|
|
});
|
|
|
|
test('fetches user from API', () async {
|
|
// Arrange
|
|
when(() => mockApi.getUser(any()))
|
|
.thenAnswer((_) async => User(name: 'Test'));
|
|
|
|
// Act
|
|
final repository = UserRepository(api: mockApi);
|
|
final user = await repository.getUser('123');
|
|
|
|
// Assert
|
|
expect(user.name, 'Test');
|
|
verify(() => mockApi.getUser('123')).called(1);
|
|
});
|
|
}
|
|
```
|
|
|
|
## Integration Tests
|
|
|
|
```dart
|
|
// 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();
|
|
|
|
testWidgets('complete login flow', (tester) async {
|
|
app.main();
|
|
await tester.pumpAndSettle();
|
|
|
|
// Navigate to login
|
|
await tester.tap(find.text('Login'));
|
|
await tester.pumpAndSettle();
|
|
|
|
// Enter credentials
|
|
await tester.enterText(
|
|
find.byKey(const Key('email_field')),
|
|
'test@example.com',
|
|
);
|
|
await tester.enterText(
|
|
find.byKey(const Key('password_field')),
|
|
'password123',
|
|
);
|
|
|
|
// Submit form
|
|
await tester.tap(find.text('Sign In'));
|
|
await tester.pumpAndSettle();
|
|
|
|
// Verify navigation to home
|
|
expect(find.text('Welcome'), findsOneWidget);
|
|
});
|
|
}
|
|
```
|
|
|
|
Run integration tests:
|
|
|
|
```bash
|
|
flutter test integration_test/app_test.dart
|
|
```
|
|
|
|
## Test Helpers
|
|
|
|
```dart
|
|
// test/helpers/pump_app.dart
|
|
extension PumpApp on WidgetTester {
|
|
Future<void> pumpApp(Widget widget, {List<Override>? overrides}) {
|
|
return pumpWidget(
|
|
ProviderScope(
|
|
overrides: overrides ?? [],
|
|
child: MaterialApp(
|
|
home: widget,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Usage
|
|
await tester.pumpApp(const MyWidget());
|
|
```
|
|
|
|
## Golden Tests
|
|
|
|
```dart
|
|
testWidgets('matches golden', (tester) async {
|
|
await tester.pumpWidget(const MaterialApp(home: MyWidget()));
|
|
|
|
await expectLater(
|
|
find.byType(MyWidget),
|
|
matchesGoldenFile('goldens/my_widget.png'),
|
|
);
|
|
});
|
|
```
|
|
|
|
Update goldens:
|
|
|
|
```bash
|
|
flutter test --update-goldens
|
|
```
|
|
|
|
## Testing Checklist
|
|
|
|
| Test Type | What to Test |
|
|
|-----------|--------------|
|
|
| Widget tests | UI rendering, user interactions, state changes |
|
|
| Bloc tests | Event → state transitions, async operations |
|
|
| Unit tests | Validators, formatters, utilities, models |
|
|
| Integration tests | Critical user flows, navigation |
|
|
|
|
---
|
|
|
|
*Flutter and flutter_test are trademarks of Google LLC.*
|