chore: bring flutter client, docs, deploy and tools into monorepo
This commit is contained in:
@@ -0,0 +1,507 @@
|
||||
# Common Testing Errors
|
||||
|
||||
## Overview
|
||||
|
||||
This guide covers frequently encountered Flutter testing errors and their solutions.
|
||||
|
||||
## Layout Errors
|
||||
|
||||
### 'A RenderFlex overflowed...'
|
||||
|
||||
**Error Message:**
|
||||
```
|
||||
The following assertion was thrown during layout:
|
||||
A RenderFlex overflowed by 1146 pixels on the right.
|
||||
```
|
||||
|
||||
**Cause:** Yellow and black stripes indicate overflow when a child widget is too large for its parent (Row/Column).
|
||||
|
||||
**Solution:** Wrap the overflowing widget in `Expanded` or `Flexible`.
|
||||
|
||||
```dart
|
||||
// Problem
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.message),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Title', style: Theme.of(context).textTheme.headlineMedium),
|
||||
Text('Very long text that overflows the available space...'),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
// Solution
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.message),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Title', style: Theme.of(context).textTheme.headlineMedium),
|
||||
Text('Very long text that fits in the available space...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
### 'Vertical viewport was given unbounded height'
|
||||
|
||||
**Error Message:**
|
||||
```
|
||||
Vertical viewport was given unbounded height.
|
||||
Viewports expand in the scrolling direction to fill their container.
|
||||
This situation typically happens when a scrollable widget is nested inside another scrollable widget.
|
||||
```
|
||||
|
||||
**Cause:** ListView or other scrollable widget inside Column without height constraints.
|
||||
|
||||
**Solution:** Wrap in `Expanded` or use `shrinkWrap: true`.
|
||||
|
||||
```dart
|
||||
// Problem
|
||||
Column(
|
||||
children: [
|
||||
Text('Header'),
|
||||
ListView(
|
||||
children: [
|
||||
ListTile(leading: Icon(Icons.map), title: Text('Map')),
|
||||
ListTile(leading: Icon(Icons.subway), title: Text('Subway')),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
// Solution 1: Expanded
|
||||
Column(
|
||||
children: [
|
||||
Text('Header'),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
children: [
|
||||
ListTile(leading: Icon(Icons.map), title: Text('Map')),
|
||||
ListTile(leading: Icon(Icons.subway), title: Text('Subway')),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
// Solution 2: shrinkWrap
|
||||
Column(
|
||||
children: [
|
||||
Text('Header'),
|
||||
ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
ListTile(leading: Icon(Icons.map), title: Text('Map')),
|
||||
ListTile(leading: Icon(Icons.subway), title: Text('Subway')),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
### 'An InputDecorator...cannot have an unbounded width'
|
||||
|
||||
**Error Message:**
|
||||
```
|
||||
An InputDecorator, which is typically created by a TextField, cannot have an unbounded width.
|
||||
This happens when the parent widget does not provide a finite width constraint.
|
||||
```
|
||||
|
||||
**Cause:** TextField or TextFormField inside Row without width constraints.
|
||||
|
||||
**Solution:** Wrap in `Expanded` or `SizedBox`.
|
||||
|
||||
```dart
|
||||
// Problem
|
||||
Row(
|
||||
children: [TextField()],
|
||||
)
|
||||
|
||||
// Solution 1: Expanded
|
||||
Row(
|
||||
children: [Expanded(child: TextFormField())],
|
||||
)
|
||||
|
||||
// Solution 2: SizedBox
|
||||
Row(
|
||||
children: [SizedBox(width: 200, child: TextFormField())],
|
||||
)
|
||||
```
|
||||
|
||||
## Widget Lifecycle Errors
|
||||
|
||||
### 'setState called during build'
|
||||
|
||||
**Error Message:**
|
||||
```
|
||||
setState() or markNeedsBuild() called during build.
|
||||
This Overlay widget cannot be marked as needing to build because the framework
|
||||
is already in the process of building widgets.
|
||||
```
|
||||
|
||||
**Cause:** Calling `setState` (or methods that call it like `showDialog`) directly in the `build` method.
|
||||
|
||||
**Solution:** Use `WidgetsBinding.instance.addPostFrameCallback` or Navigator API.
|
||||
|
||||
```dart
|
||||
// Problem
|
||||
Widget build(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(title: Text('Error!')),
|
||||
);
|
||||
return Center(child: Text('Show Dialog'));
|
||||
}
|
||||
|
||||
// Solution 1: Post-frame callback
|
||||
Widget build(BuildContext context) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(title: Text('Success!')),
|
||||
);
|
||||
});
|
||||
return Center(child: Text('Show Dialog'));
|
||||
}
|
||||
|
||||
// Solution 2: Navigator API (for initial navigation)
|
||||
Widget build(BuildContext context) {
|
||||
return ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
PageRouteBuilder(
|
||||
pageBuilder: (_, __, ___) => DialogScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text('Show Dialog'),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Parent Widget Errors
|
||||
|
||||
### 'Incorrect use of ParentData widget'
|
||||
|
||||
**Error Message:**
|
||||
```
|
||||
The following assertion was thrown while looking for parent data:
|
||||
Incorrect use of ParentDataWidget.
|
||||
Usually, this indicates that at least one of the offending ParentDataWidgets
|
||||
listed above is not placed directly inside a compatible ancestor widget.
|
||||
```
|
||||
|
||||
**Cause:** Using widgets that require specific parent widgets in wrong context.
|
||||
|
||||
**Common Solutions:**
|
||||
|
||||
| Widget | Expected Parent | Solution |
|
||||
|---------------------|---------------------|-----------------------------------|
|
||||
| `Flexible` | `Row`, `Column`, `Flex` | Ensure parent is Row/Column/Flex |
|
||||
| `Expanded` | `Row`, `Column`, `Flex` | Ensure parent is Row/Column/Flex |
|
||||
| `Positioned` | `Stack` | Wrap in Stack |
|
||||
| `TableCell` | `Table` | Ensure parent is Table |
|
||||
|
||||
```dart
|
||||
// Problem
|
||||
Column(
|
||||
children: [
|
||||
Expanded(child: Text('Wrong!')), // Expanded needs Row/Column parent, not Column children
|
||||
],
|
||||
)
|
||||
|
||||
// Solution
|
||||
Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Text('Correct!')),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
## Testing-Specific Errors
|
||||
|
||||
### 'WidgetTester.pumpWidget() called with a widget that doesn't include a MaterialApp'
|
||||
|
||||
**Error Message:**
|
||||
```
|
||||
WidgetTester.pumpWidget() called with a widget that doesn't include a MaterialApp.
|
||||
```
|
||||
|
||||
**Cause:** Pumping widget without MaterialApp context.
|
||||
|
||||
**Solution:** Wrap widget in MaterialApp.
|
||||
|
||||
```dart
|
||||
// Problem
|
||||
testWidgets('test without MaterialApp', (tester) async {
|
||||
await tester.pumpWidget(MyWidget());
|
||||
});
|
||||
|
||||
// Solution
|
||||
testWidgets('test with MaterialApp', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: MyWidget(),
|
||||
),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### 'No Finder found'
|
||||
|
||||
**Error Message:**
|
||||
```
|
||||
No Finder found.
|
||||
Test failed: No elements found matching the finder.
|
||||
```
|
||||
|
||||
**Cause:** Widget not found in tree at time of test.
|
||||
|
||||
**Common Solutions:**
|
||||
|
||||
```dart
|
||||
// 1. Wrong text/content
|
||||
expect(find.text('Wrong Text'), findsNothing); // Error
|
||||
|
||||
// Solution: Use correct text
|
||||
expect(find.text('Correct Text'), findsOneWidget);
|
||||
|
||||
// 2. Widget not built yet
|
||||
testWidgets('widget not built', (tester) async {
|
||||
await tester.pumpWidget(MyWidget());
|
||||
expect(find.text('Async Text'), findsOneWidget); // Error
|
||||
});
|
||||
|
||||
// Solution: Pump and settle for async operations
|
||||
testWidgets('widget not built', (tester) async {
|
||||
await tester.pumpWidget(MyWidget());
|
||||
await tester.pumpAndSettle(); // Wait for async operations
|
||||
expect(find.text('Async Text'), findsOneWidget);
|
||||
});
|
||||
|
||||
// 3. Wrong finder type
|
||||
testWidgets('wrong finder', (tester) async {
|
||||
await tester.pumpWidget(TextButton(child: Text('Button')));
|
||||
await tester.tap(find.byType(ElevatedButton)); // Error
|
||||
});
|
||||
|
||||
// Solution: Use correct type or use Text finder
|
||||
testWidgets('correct finder', (tester) async {
|
||||
await tester.pumpWidget(TextButton(child: Text('Button')));
|
||||
await tester.tap(find.byType(TextButton)); // Correct
|
||||
});
|
||||
```
|
||||
|
||||
## Plugin/Platform Errors
|
||||
|
||||
### 'MissingPluginException'
|
||||
|
||||
**Error Message:**
|
||||
```
|
||||
MissingPluginException(No implementation found for method MethodName on channel channel.name)
|
||||
```
|
||||
|
||||
**Cause:** Plugin not registered or mock not set up in tests.
|
||||
|
||||
**Solution:** Mock the method channel in tests.
|
||||
|
||||
```dart
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel('your.plugin.channel'),
|
||||
(MethodCall methodCall) async {
|
||||
if (methodCall.method == 'getPlatformVersion') {
|
||||
return 'Android 12';
|
||||
}
|
||||
throw MissingPluginException();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel('your.plugin.channel'),
|
||||
null,
|
||||
);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Async Errors
|
||||
|
||||
### 'TimeoutException'
|
||||
|
||||
**Error Message:**
|
||||
```
|
||||
TimeoutException after 0:00:05.000000: Test timed out after 5 seconds.
|
||||
```
|
||||
|
||||
**Cause:** Async operation not completing or `pumpAndSettle()` waiting indefinitely.
|
||||
|
||||
**Solution:** Set timeout or fix infinite loops.
|
||||
|
||||
```dart
|
||||
// Solution 1: Increase timeout
|
||||
testWidgets(
|
||||
'long-running test',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(MyWidget());
|
||||
await Future.delayed(const Duration(seconds: 10));
|
||||
},
|
||||
timeout: const Timeout(Duration(minutes: 1)),
|
||||
);
|
||||
|
||||
// Solution 2: Fix infinite pumpAndSettle
|
||||
testWidgets('fix infinite settle', (tester) async {
|
||||
await tester.pumpWidget(MyWidget());
|
||||
await tester.pump(); // Use pump() instead of pumpAndSettle() if animation loops
|
||||
});
|
||||
```
|
||||
|
||||
## Data Errors
|
||||
|
||||
### 'RangeError'
|
||||
|
||||
**Error Message:**
|
||||
```
|
||||
RangeError: Index out of range: index should be less than 5, but is 5
|
||||
```
|
||||
|
||||
**Cause:** Accessing list/array with invalid index.
|
||||
|
||||
**Solution:** Check bounds before access or handle empty cases.
|
||||
|
||||
```dart
|
||||
// Problem
|
||||
testWidgets('range error', (tester) async {
|
||||
final items = [1, 2, 3, 4, 5];
|
||||
expect(items[5], 6); // Error: index 5 out of range
|
||||
});
|
||||
|
||||
// Solution 1: Check bounds
|
||||
testWidgets('safe access', (tester) async {
|
||||
final items = [1, 2, 3, 4, 5];
|
||||
if (items.length > 5) {
|
||||
expect(items[5], 6);
|
||||
}
|
||||
});
|
||||
|
||||
// Solution 2: Handle empty lists
|
||||
testWidgets('handle empty list', (tester) async {
|
||||
final items = <int>[];
|
||||
if (items.isEmpty) {
|
||||
expect(find.text('No items'), findsOneWidget);
|
||||
} else {
|
||||
expect(items.first, 1);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
### Enable Verbose Logging
|
||||
|
||||
```bash
|
||||
flutter test --verbose
|
||||
```
|
||||
|
||||
### Take Screenshots in Integration Tests
|
||||
|
||||
```dart
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
testWidgets('debug with screenshot', (tester) async {
|
||||
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized()
|
||||
as IntegrationTestWidgetsFlutterBinding;
|
||||
|
||||
await tester.pumpWidget(MyWidget());
|
||||
await binding.convertFlutterSurfaceToImage();
|
||||
await tester.pump();
|
||||
await binding.takeScreenshot('debug-state');
|
||||
});
|
||||
```
|
||||
|
||||
### Print Widget Tree
|
||||
|
||||
```dart
|
||||
testWidgets('print tree', (tester) async {
|
||||
await tester.pumpWidget(MyWidget());
|
||||
debugDumpApp();
|
||||
});
|
||||
```
|
||||
|
||||
### Use Debug Flags
|
||||
|
||||
```dart
|
||||
testWidgets('debug flags', (tester) async {
|
||||
debugPrint('Current state: ...');
|
||||
await tester.pumpWidget(MyWidget());
|
||||
debugPrint('Widget tree: ${tester.widgetList(find.byType(MyWidget))}');
|
||||
});
|
||||
```
|
||||
|
||||
## Prevention
|
||||
|
||||
### Use Type Safety
|
||||
|
||||
```dart
|
||||
// Good: Use type-safe access
|
||||
final text = tester.widget<Text>(find.byType(Text));
|
||||
|
||||
// Avoid: Unsafe casts
|
||||
final text = find.byType(Text).evaluate().first.widget as Text;
|
||||
```
|
||||
|
||||
### Check Before Actions
|
||||
|
||||
```dart
|
||||
testWidgets('safe tap', (tester) async {
|
||||
await tester.pumpWidget(MyWidget());
|
||||
|
||||
final button = find.byType(ElevatedButton);
|
||||
if (button.evaluate().isNotEmpty) {
|
||||
await tester.tap(button);
|
||||
} else {
|
||||
fail('Button not found');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Handle All Cases
|
||||
|
||||
```dart
|
||||
testWidgets('comprehensive', (tester) async {
|
||||
await tester.pumpWidget(MyWidget());
|
||||
|
||||
// Check all possible states
|
||||
if (find.text('Loading').evaluate().isNotEmpty) {
|
||||
// Handle loading state
|
||||
} else if (find.text('Error').evaluate().isNotEmpty) {
|
||||
// Handle error state
|
||||
} else {
|
||||
// Handle success state
|
||||
}
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,562 @@
|
||||
# Integration Testing Guide
|
||||
|
||||
## Overview
|
||||
|
||||
Integration tests test complete apps or large parts of apps on real devices or emulators. They verify that all widgets and services work together as expected and can measure performance.
|
||||
|
||||
## When to Write Integration Tests
|
||||
|
||||
- Testing complete user flows across multiple screens
|
||||
- Verifying navigation between pages
|
||||
- Testing state persistence
|
||||
- Validating real device behavior (sensors, camera, etc.)
|
||||
- Performance profiling and benchmarking
|
||||
- Testing platform-specific features
|
||||
|
||||
## Setup
|
||||
|
||||
### Add Dependency
|
||||
|
||||
Add to `pubspec.yaml`:
|
||||
|
||||
```yaml
|
||||
dev_dependencies:
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
```
|
||||
|
||||
### Create Test Driver
|
||||
|
||||
Create `test_driver/integration_test.dart` when using `flutter drive`, browser
|
||||
integration tests, screenshots, or response data from performance traces:
|
||||
|
||||
```dart
|
||||
import 'package:integration_test/integration_test_driver.dart';
|
||||
|
||||
Future<void> main() => integrationDriver();
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
### Basic Integration Test
|
||||
|
||||
```dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:my_app/main.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('app launches and displays home', (tester) async {
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
expect(find.text('Home Screen'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Running Integration Tests
|
||||
|
||||
```bash
|
||||
# Run on all devices
|
||||
flutter test integration_test/
|
||||
|
||||
# Run on specific device
|
||||
flutter test -d <device-id> integration_test/my_test.dart
|
||||
|
||||
# Run specific test file
|
||||
flutter test integration_test/my_test.dart
|
||||
|
||||
# Run a browser/driver integration test
|
||||
flutter drive \
|
||||
--driver=test_driver/integration_test.dart \
|
||||
--target=integration_test/my_test.dart \
|
||||
-d chrome
|
||||
```
|
||||
|
||||
## Testing User Flows
|
||||
|
||||
### Multi-Screen Navigation
|
||||
|
||||
```dart
|
||||
testWidgets('complete user flow from login to dashboard', (tester) async {
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
// Login screen
|
||||
expect(find.text('Login'), findsOneWidget);
|
||||
|
||||
// Enter credentials
|
||||
await tester.enterText(find.byKey(const Key('username')), 'user@example.com');
|
||||
await tester.enterText(find.byKey(const Key('password')), 'password123');
|
||||
|
||||
// Tap login
|
||||
await tester.tap(find.byKey(const Key('login-button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Dashboard screen
|
||||
expect(find.text('Dashboard'), findsOneWidget);
|
||||
expect(find.text('Welcome, user@example.com'), findsOneWidget);
|
||||
});
|
||||
```
|
||||
|
||||
### Tab Navigation
|
||||
|
||||
```dart
|
||||
testWidgets('navigate between tabs', (tester) async {
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
// Initial tab
|
||||
expect(find.text('Home'), findsOneWidget);
|
||||
|
||||
// Tap second tab
|
||||
await tester.tap(find.text('Profile'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Home'), findsNothing);
|
||||
expect(find.text('Profile'), findsOneWidget);
|
||||
|
||||
// Navigate back
|
||||
await tester.tap(find.text('Home'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Home'), findsOneWidget);
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Forms
|
||||
|
||||
### Complete Form Submission
|
||||
|
||||
```dart
|
||||
testWidgets('submit registration form', (tester) async {
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
// Navigate to registration
|
||||
await tester.tap(find.text('Sign Up'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Fill form
|
||||
await tester.enterText(find.byKey(const Key('name')), 'John Doe');
|
||||
await tester.enterText(find.byKey(const Key('email')), 'john@example.com');
|
||||
await tester.enterText(find.byKey(const Key('password')), 'password123');
|
||||
await tester.enterText(find.byKey(const Key('confirm-password')), 'password123');
|
||||
|
||||
// Submit
|
||||
await tester.tap(find.byKey(const Key('submit')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify success
|
||||
expect(find.text('Registration Successful'), findsOneWidget);
|
||||
});
|
||||
```
|
||||
|
||||
### Form Validation
|
||||
|
||||
```dart
|
||||
testWidgets('form shows validation errors', (tester) async {
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
await tester.tap(find.text('Sign Up'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Submit empty form
|
||||
await tester.tap(find.byKey(const Key('submit')));
|
||||
await tester.pump();
|
||||
|
||||
// Verify errors
|
||||
expect(find.text('Name is required'), findsOneWidget);
|
||||
expect(find.text('Email is required'), findsOneWidget);
|
||||
expect(find.text('Password is required'), findsOneWidget);
|
||||
|
||||
// Fill with invalid email
|
||||
await tester.enterText(find.byKey(const Key('email')), 'invalid-email');
|
||||
await tester.tap(find.byKey(const Key('submit')));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Invalid email format'), findsOneWidget);
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Lists and Data
|
||||
|
||||
### Loading and Displaying Data
|
||||
|
||||
```dart
|
||||
testWidgets('load and display items from API', (tester) async {
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
// Wait for data load
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify items loaded
|
||||
expect(find.byType(ListTile), findsWidgets);
|
||||
expect(find.text('Item 1'), findsOneWidget);
|
||||
expect(find.text('Item 2'), findsOneWidget);
|
||||
});
|
||||
```
|
||||
|
||||
### Pull-to-Refresh
|
||||
|
||||
```dart
|
||||
testWidgets('pull to refresh updates list', (tester) async {
|
||||
await tester.pumpWidget(const MyApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Pull down
|
||||
await tester.drag(
|
||||
find.byType(RefreshIndicator),
|
||||
const Offset(0, 300),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify refreshed data
|
||||
expect(find.text('Updated Item 1'), findsOneWidget);
|
||||
});
|
||||
```
|
||||
|
||||
### Infinite Scrolling
|
||||
|
||||
```dart
|
||||
testWidgets('load more items on scroll', (tester) async {
|
||||
await tester.pumpWidget(const MyApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Initial items
|
||||
expect(find.text('Item 20'), findsNothing);
|
||||
|
||||
// Scroll to bottom to trigger load more
|
||||
await tester.fling(
|
||||
find.byType(ListView),
|
||||
const Offset(0, -2000),
|
||||
10000,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// New items loaded
|
||||
expect(find.text('Item 20'), findsOneWidget);
|
||||
});
|
||||
```
|
||||
|
||||
## Testing State Persistence
|
||||
|
||||
### Persisted Settings
|
||||
|
||||
```dart
|
||||
testWidgets('user preference persists across app rebuild', (tester) async {
|
||||
final preferences = FakePreferencesStore();
|
||||
|
||||
await tester.pumpWidget(MyApp(preferences: preferences));
|
||||
|
||||
// Change setting
|
||||
await tester.tap(find.text('Dark Mode'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Rebuild the app with the same injected storage.
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pumpWidget(MyApp(preferences: preferences));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify setting persisted
|
||||
expect(find.text('Light Mode'), findsNothing);
|
||||
expect(find.text('Dark Mode'), findsOneWidget);
|
||||
});
|
||||
```
|
||||
|
||||
### Authentication State
|
||||
|
||||
```dart
|
||||
testWidgets('user stays logged in', (tester) async {
|
||||
final authStore = FakeAuthStore();
|
||||
|
||||
await tester.pumpWidget(MyApp(authStore: authStore));
|
||||
|
||||
// Login
|
||||
await tester.enterText(find.byKey(const Key('email')), 'user@example.com');
|
||||
await tester.enterText(find.byKey(const Key('password')), 'password');
|
||||
await tester.tap(find.byKey(const Key('login')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Dashboard'), findsOneWidget);
|
||||
|
||||
// Rebuild the app with the same injected auth store.
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pumpWidget(MyApp(authStore: authStore));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Still logged in
|
||||
expect(find.text('Dashboard'), findsOneWidget);
|
||||
expect(find.text('Login'), findsNothing);
|
||||
});
|
||||
```
|
||||
|
||||
## Performance Testing
|
||||
|
||||
### Measuring Frame Time
|
||||
|
||||
```dart
|
||||
testWidgets('measure scrolling performance', (tester) async {
|
||||
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized()
|
||||
as IntegrationTestWidgetsFlutterBinding;
|
||||
|
||||
await tester.pumpWidget(const MyApp());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await binding.watchPerformance(() async {
|
||||
await tester.fling(
|
||||
find.byType(ListView),
|
||||
const Offset(0, -1000),
|
||||
5000,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}, reportKey: 'scrolling_performance');
|
||||
});
|
||||
```
|
||||
|
||||
### Tracking Build Count
|
||||
|
||||
```dart
|
||||
testWidgets('widget doesn't rebuild unnecessarily', (tester) async {
|
||||
int buildCount = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) {
|
||||
buildCount++;
|
||||
return const MyWidget();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(buildCount, 1);
|
||||
|
||||
// Perform unrelated action
|
||||
await tester.tap(find.byType(ElevatedButton));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Should not rebuild
|
||||
expect(buildCount, 1);
|
||||
});
|
||||
```
|
||||
|
||||
### Memory Tracking on Native Targets
|
||||
|
||||
```dart
|
||||
import 'dart:io' show ProcessInfo;
|
||||
|
||||
testWidgets('monitor memory usage during scrolling', (tester) async {
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
final initialMemory = ProcessInfo.currentRss;
|
||||
|
||||
await tester.fling(
|
||||
find.byType(ListView),
|
||||
const Offset(0, -5000),
|
||||
10000,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final finalMemory = ProcessInfo.currentRss;
|
||||
final memoryIncrease = finalMemory - initialMemory;
|
||||
|
||||
// Memory increase should be reasonable (< 10MB)
|
||||
expect(memoryIncrease, lessThan(10 * 1024 * 1024));
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Platform-Specific Features
|
||||
|
||||
### Permissions
|
||||
|
||||
```dart
|
||||
testWidgets('request and handle camera permission', (tester) async {
|
||||
final permissions = FakePermissionGateway(
|
||||
camera: PermissionStatus.granted,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(MyApp(permissions: permissions));
|
||||
|
||||
// Request camera access
|
||||
await tester.tap(find.text('Take Photo'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify camera screen appears
|
||||
expect(find.byType(CameraPreview), findsOneWidget);
|
||||
});
|
||||
```
|
||||
|
||||
### Deep Links
|
||||
|
||||
```dart
|
||||
testWidgets('handle deep link to product page', (tester) async {
|
||||
await tester.pumpWidget(const MyApp(initialRoute: '/product/123'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Product #123'), findsOneWidget);
|
||||
});
|
||||
```
|
||||
|
||||
### In-App Purchases
|
||||
|
||||
```dart
|
||||
testWidgets('complete purchase flow', (tester) async {
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
await tester.tap(find.text('Premium'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Subscribe'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify purchase completed
|
||||
expect(find.text('Premium Active'), findsOneWidget);
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Network
|
||||
|
||||
### Offline Behavior
|
||||
|
||||
```dart
|
||||
testWidgets('handle offline state gracefully', (tester) async {
|
||||
final connectivity = FakeConnectivity(isOnline: false);
|
||||
final api = FakeApiClient(networkError: const SocketException('offline'));
|
||||
|
||||
await tester.pumpWidget(MyApp(connectivity: connectivity, apiClient: api));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify offline message
|
||||
expect(find.text('No internet connection'), findsOneWidget);
|
||||
|
||||
// Simulate back online
|
||||
connectivity.isOnline = true;
|
||||
api.networkError = null;
|
||||
await tester.tap(find.byKey(const Key('retry-button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Reload data
|
||||
expect(find.text('Data loaded'), findsOneWidget);
|
||||
});
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```dart
|
||||
testWidgets('handle network errors', (tester) async {
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
// Trigger API call
|
||||
await tester.tap(find.text('Load Data'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify error message displayed
|
||||
expect(find.text('Failed to load data'), findsOneWidget);
|
||||
expect(find.byKey(const Key('retry-button')), findsOneWidget);
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Animations
|
||||
|
||||
### Smooth Animations
|
||||
|
||||
```dart
|
||||
testWidgets('animation runs smoothly', (tester) async {
|
||||
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized()
|
||||
as IntegrationTestWidgetsFlutterBinding;
|
||||
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
await binding.traceAction(() async {
|
||||
await tester.tap(find.byKey(const Key('animate')));
|
||||
await tester.pumpAndSettle();
|
||||
}, reportKey: 'animation_timeline');
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Test critical user paths** - Focus on important flows
|
||||
2. **Keep tests independent** - Each test should be standalone
|
||||
3. **Use descriptive names** - Explain what the test does
|
||||
4. **Wait for async operations** - Use `pumpAndSettle()`
|
||||
5. **Test real behavior** - Verify actual device behavior, not mocks
|
||||
6. **Measure performance** - Track frame times, memory, and build counts
|
||||
7. **Test edge cases** - Offline, errors, empty states
|
||||
8. **Keep tests fast** - Avoid unnecessary delays
|
||||
|
||||
## Debugging Integration Tests
|
||||
|
||||
### Take Screenshots
|
||||
|
||||
```dart
|
||||
testWidgets('debug test with screenshot', (tester) async {
|
||||
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized()
|
||||
as IntegrationTestWidgetsFlutterBinding;
|
||||
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
// On Android, call convertFlutterSurfaceToImage and pump before screenshots.
|
||||
await binding.convertFlutterSurfaceToImage();
|
||||
await tester.pump();
|
||||
await binding.takeScreenshot('test-screenshot');
|
||||
|
||||
// Perform actions
|
||||
await tester.tap(find.text('Button'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Take another screenshot
|
||||
await binding.takeScreenshot('after-tap');
|
||||
});
|
||||
```
|
||||
|
||||
### Print Widget Tree
|
||||
|
||||
```dart
|
||||
testWidgets('print widget tree for debugging', (tester) async {
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
// Print widget tree
|
||||
debugDumpApp();
|
||||
});
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
```yaml
|
||||
name: Integration Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.19.0'
|
||||
|
||||
- name: Get dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Run integration tests
|
||||
run: flutter test integration_test/
|
||||
```
|
||||
|
||||
### Test Reports
|
||||
|
||||
```bash
|
||||
# Generate test report
|
||||
flutter test integration_test/ --reporter expanded
|
||||
|
||||
# Generate JSON report
|
||||
flutter test integration_test/ --reporter json > test-results.json
|
||||
```
|
||||
@@ -0,0 +1,588 @@
|
||||
# Mocking Guide
|
||||
|
||||
## Overview
|
||||
|
||||
Mocking replaces real dependencies with test doubles to isolate code and make tests deterministic. This guide covers mocking strategies for Flutter tests.
|
||||
|
||||
## When to Mock
|
||||
|
||||
- Testing code that depends on external services (APIs, databases)
|
||||
- Isolating components for unit tests
|
||||
- Simulating error conditions and edge cases
|
||||
- Making tests faster and more reliable
|
||||
- Testing code that uses plugins
|
||||
|
||||
## Using Mockito
|
||||
|
||||
### Setup
|
||||
|
||||
Add to `pubspec.yaml`:
|
||||
|
||||
```yaml
|
||||
dev_dependencies:
|
||||
build_runner: ^2.10.4
|
||||
mockito: ^5.6.1
|
||||
```
|
||||
|
||||
### Generate Mocks
|
||||
|
||||
Create a test file with a Mockito annotation and import the generated file:
|
||||
|
||||
```dart
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import 'user_repository_test.mocks.dart';
|
||||
|
||||
@GenerateMocks([ApiClient, UserRepository])
|
||||
void main() {}
|
||||
```
|
||||
|
||||
Generate mocks:
|
||||
|
||||
```bash
|
||||
dart run build_runner build
|
||||
```
|
||||
|
||||
### Basic Mocking
|
||||
|
||||
```dart
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import 'my_service_test.mocks.dart';
|
||||
|
||||
@GenerateMocks([ApiService])
|
||||
void main() {
|
||||
late MockApiService mockApiService;
|
||||
late MyService myService;
|
||||
|
||||
setUp(() {
|
||||
mockApiService = MockApiService();
|
||||
myService = MyService(mockApiService);
|
||||
});
|
||||
|
||||
test('calls API and returns data', () async {
|
||||
// Arrange
|
||||
when(mockApiService.fetchData())
|
||||
.thenAnswer((_) async => {'data': 'value'});
|
||||
|
||||
// Act
|
||||
final result = await myService.getData();
|
||||
|
||||
// Assert
|
||||
expect(result['data'], 'value');
|
||||
verify(mockApiService.fetchData()).called(1);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Mocking Different Types
|
||||
|
||||
### Mocking Classes
|
||||
|
||||
```dart
|
||||
class DataService {
|
||||
Future<String> fetchData() async => 'real data';
|
||||
}
|
||||
|
||||
// In a real test file, generate MockDataService with @GenerateMocks([DataService]).
|
||||
test('mocks class method', () async {
|
||||
final mockService = MockDataService();
|
||||
|
||||
when(mockService.fetchData())
|
||||
.thenAnswer((_) async => 'mocked data');
|
||||
|
||||
final result = await mockService.fetchData();
|
||||
expect(result, 'mocked data');
|
||||
});
|
||||
```
|
||||
|
||||
### Mocking Interfaces
|
||||
|
||||
```dart
|
||||
abstract class Storage {
|
||||
void save(String key, String value);
|
||||
String? load(String key);
|
||||
}
|
||||
|
||||
class FakeStorage implements Storage {
|
||||
final values = <String, String>{};
|
||||
|
||||
@override
|
||||
void save(String key, String value) {
|
||||
values[key] = value;
|
||||
}
|
||||
|
||||
@override
|
||||
String? load(String key) => values[key];
|
||||
}
|
||||
|
||||
// Test
|
||||
test('uses a fake storage implementation', () {
|
||||
final storage = FakeStorage();
|
||||
|
||||
storage.save('key', 'value');
|
||||
|
||||
expect(storage.load('key'), 'value');
|
||||
});
|
||||
```
|
||||
|
||||
### Mocking Streams
|
||||
|
||||
```dart
|
||||
class StreamService {
|
||||
Stream<int> get counterStream => StreamController<int>().stream;
|
||||
}
|
||||
|
||||
// Test
|
||||
test('mocks stream', () async {
|
||||
final mockService = MockStreamService();
|
||||
|
||||
final controller = StreamController<int>();
|
||||
when(mockService.counterStream).thenAnswer((_) => controller.stream);
|
||||
|
||||
expectLater(
|
||||
mockService.counterStream,
|
||||
emitsInOrder([1, 2, 3]),
|
||||
);
|
||||
|
||||
controller.add(1);
|
||||
controller.add(2);
|
||||
controller.add(3);
|
||||
await controller.close();
|
||||
});
|
||||
```
|
||||
|
||||
## Mockito Features
|
||||
|
||||
### Returning Values
|
||||
|
||||
```dart
|
||||
when(mockService.getData()).thenReturn('value');
|
||||
|
||||
when(mockService.getUser(id)).thenReturn(User(id: id, name: 'Test'));
|
||||
|
||||
when(mockService.getList()).thenReturn([1, 2, 3]);
|
||||
```
|
||||
|
||||
### Throwing Errors
|
||||
|
||||
```dart
|
||||
when(mockService.getData())
|
||||
.thenThrow(Exception('Network error'));
|
||||
|
||||
when(mockService.saveData(data))
|
||||
.thenThrow(FormatException('Invalid data'));
|
||||
```
|
||||
|
||||
### Async Responses
|
||||
|
||||
```dart
|
||||
when(mockService.fetchData())
|
||||
.thenAnswer((_) async => 'async data');
|
||||
|
||||
when(mockService.loadUser(id))
|
||||
.thenAnswer((_) async => Future.delayed(
|
||||
const Duration(milliseconds: 100),
|
||||
() => User(id: id),
|
||||
));
|
||||
```
|
||||
|
||||
### Sequential Returns
|
||||
|
||||
```dart
|
||||
when(mockService.getData())
|
||||
.thenReturn('first')
|
||||
.thenReturn('second')
|
||||
.thenThrow(Exception('error'));
|
||||
|
||||
// First call returns 'first'
|
||||
expect(await mockService.getData(), 'first');
|
||||
|
||||
// Second call returns 'second'
|
||||
expect(await mockService.getData(), 'second');
|
||||
|
||||
// Third call throws
|
||||
await expectLater(mockService.getData(), throwsException);
|
||||
```
|
||||
|
||||
### Conditional Returns
|
||||
|
||||
```dart
|
||||
when(mockService.getUser(argThat(allOf(isA<String>(), contains('@')))))
|
||||
.thenReturn(User(email: 'test@example.com'));
|
||||
|
||||
when(mockService.getUser(argThat(predicate<String>((arg) => arg.length > 5))))
|
||||
.thenReturn(User(name: 'Long Name'));
|
||||
|
||||
// Matches email format
|
||||
expect(mockService.getUser('test@example.com').email, 'test@example.com');
|
||||
|
||||
// Matches length > 5
|
||||
expect(mockService.getUser('Long Name').name, 'Long Name');
|
||||
```
|
||||
|
||||
### Capturing Arguments
|
||||
|
||||
```dart
|
||||
mockService.save('key1', 'value1');
|
||||
mockService.save('key2', 'value2');
|
||||
|
||||
final captured = verify(mockService.save(captureAny, captureAny)).captured;
|
||||
|
||||
expect(captured, ['key1', 'value1', 'key2', 'value2']);
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
### Verify Calls
|
||||
|
||||
```dart
|
||||
mockService.getData();
|
||||
|
||||
verify(mockService.getData()).called(1);
|
||||
verify(mockService.getData()).called(greaterThan(0));
|
||||
verifyNever(mockService.otherMethod());
|
||||
```
|
||||
|
||||
### Verify In Order
|
||||
|
||||
```dart
|
||||
mockService.first();
|
||||
mockService.second();
|
||||
mockService.third();
|
||||
|
||||
verifyInOrder([
|
||||
mockService.first(),
|
||||
mockService.second(),
|
||||
mockService.third(),
|
||||
]);
|
||||
```
|
||||
|
||||
### Verify Specific Arguments
|
||||
|
||||
```dart
|
||||
mockService.save('key', 'value');
|
||||
|
||||
verify(mockService.save('key', 'value')).called(1);
|
||||
verify(mockService.save(argThat(isA<String>()), any)).called(1);
|
||||
```
|
||||
|
||||
### Verify No More Interactions
|
||||
|
||||
```dart
|
||||
mockService.getData();
|
||||
|
||||
verify(mockService.getData()).called(1);
|
||||
verifyNoMoreInteractions(mockService);
|
||||
|
||||
verifyNoInteractions(otherMockService);
|
||||
```
|
||||
|
||||
## Mocking Platform Channels
|
||||
|
||||
### Basic Platform Channel Mock
|
||||
|
||||
```dart
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel('com.example.app/channel'),
|
||||
(MethodCall methodCall) async {
|
||||
switch (methodCall.method) {
|
||||
case 'getBatteryLevel':
|
||||
return 85;
|
||||
case 'openUrl':
|
||||
return true;
|
||||
default:
|
||||
throw MissingPluginException();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel('com.example.app/channel'),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('gets battery level', () async {
|
||||
final platform = MethodChannel('com.example.app/channel');
|
||||
final result = await platform.invokeMethod('getBatteryLevel');
|
||||
expect(result, 85);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Platform Channel with Arguments
|
||||
|
||||
```dart
|
||||
setUp(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel('com.example.app/storage'),
|
||||
(MethodCall methodCall) async {
|
||||
if (methodCall.method == 'save') {
|
||||
final args = methodCall.arguments as Map;
|
||||
storage[args['key']] = args['value'];
|
||||
return true;
|
||||
} else if (methodCall.method == 'load') {
|
||||
final args = methodCall.arguments as Map;
|
||||
return storage[args['key']];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('saves and loads from platform storage', () async {
|
||||
final platform = MethodChannel('com.example.app/storage');
|
||||
|
||||
await platform.invokeMethod('save', {'key': 'test', 'value': 'data'});
|
||||
final result = await platform.invokeMethod('load', {'key': 'test'});
|
||||
|
||||
expect(result, 'data');
|
||||
});
|
||||
```
|
||||
|
||||
## Mocking Repositories
|
||||
|
||||
### Data Repository
|
||||
|
||||
```dart
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import 'user_repository_test.mocks.dart';
|
||||
|
||||
class UserRepository {
|
||||
final ApiClient apiClient;
|
||||
|
||||
UserRepository(this.apiClient);
|
||||
|
||||
Future<User> getUser(String id) async {
|
||||
final data = await apiClient.get('/users/$id');
|
||||
return User.fromJson(data);
|
||||
}
|
||||
}
|
||||
|
||||
@GenerateMocks([ApiClient])
|
||||
void main() {
|
||||
late MockApiClient mockApiClient;
|
||||
late UserRepository userRepository;
|
||||
|
||||
setUp(() {
|
||||
mockApiClient = MockApiClient();
|
||||
userRepository = UserRepository(mockApiClient);
|
||||
});
|
||||
|
||||
test('fetches and parses user', () async {
|
||||
when(mockApiClient.get('/users/123'))
|
||||
.thenAnswer((_) async => {
|
||||
'id': '123',
|
||||
'name': 'John Doe',
|
||||
'email': 'john@example.com',
|
||||
});
|
||||
|
||||
final user = await userRepository.getUser('123');
|
||||
|
||||
expect(user.id, '123');
|
||||
expect(user.name, 'John Doe');
|
||||
verify(mockApiClient.get('/users/123')).called(1);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Mocking State Management
|
||||
|
||||
### BLoC Mock
|
||||
|
||||
```dart
|
||||
class CounterBloc extends Bloc<CounterEvent, CounterState> {
|
||||
CounterBloc() : super(CounterInitial()) {
|
||||
on<IncrementEvent>((event, emit) => emit(CounterIncremented()));
|
||||
}
|
||||
}
|
||||
|
||||
// Test
|
||||
test('emits states on increment', () {
|
||||
final bloc = CounterBloc();
|
||||
|
||||
expectLater(bloc, emitsInOrder([
|
||||
CounterInitial(),
|
||||
CounterIncremented(),
|
||||
]));
|
||||
|
||||
bloc.add(IncrementEvent());
|
||||
});
|
||||
```
|
||||
|
||||
### Provider Mock
|
||||
|
||||
```dart
|
||||
class AuthProvider extends ChangeNotifier {
|
||||
bool _isLoggedIn = false;
|
||||
bool get isLoggedIn => _isLoggedIn;
|
||||
|
||||
void login() {
|
||||
_isLoggedIn = true;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Test
|
||||
test('notifies listeners on login', () {
|
||||
final provider = AuthProvider();
|
||||
bool notified = false;
|
||||
|
||||
provider.addListener(() {
|
||||
notified = true;
|
||||
});
|
||||
|
||||
provider.login();
|
||||
|
||||
expect(notified, true);
|
||||
expect(provider.isLoggedIn, true);
|
||||
});
|
||||
```
|
||||
|
||||
## Manual Mocks
|
||||
|
||||
### Simple Manual Mock
|
||||
|
||||
```dart
|
||||
class ApiService {
|
||||
Future<String> fetchData() async => 'real data';
|
||||
}
|
||||
|
||||
class MockApiService implements ApiService {
|
||||
String? _mockedResponse;
|
||||
|
||||
void setMockResponse(String response) {
|
||||
_mockedResponse = response;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> fetchData() async => _mockedResponse ?? 'default mock';
|
||||
}
|
||||
|
||||
// Test
|
||||
test('uses manual mock', () async {
|
||||
final mockService = MockApiService();
|
||||
mockService.setMockResponse('test data');
|
||||
|
||||
final result = await mockService.fetchData();
|
||||
expect(result, 'test data');
|
||||
});
|
||||
```
|
||||
|
||||
### State-Based Mock
|
||||
|
||||
```dart
|
||||
class StatefulMockService implements ApiService {
|
||||
final List<String> responses = [];
|
||||
int _callCount = 0;
|
||||
|
||||
@override
|
||||
Future<String> fetchData() async {
|
||||
if (_callCount < responses.length) {
|
||||
return responses[_callCount++];
|
||||
}
|
||||
throw Exception('No more responses');
|
||||
}
|
||||
|
||||
void addResponse(String response) {
|
||||
responses.add(response);
|
||||
}
|
||||
}
|
||||
|
||||
// Test
|
||||
test('returns sequential responses', () async {
|
||||
final mock = StatefulMockService();
|
||||
mock.addResponse('first');
|
||||
mock.addResponse('second');
|
||||
|
||||
expect(await mock.fetchData(), 'first');
|
||||
expect(await mock.fetchData(), 'second');
|
||||
|
||||
await expectLater(mock.fetchData(), throwsException);
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Mock at boundaries** - Mock external dependencies, not internal logic
|
||||
2. **Avoid over-mocking** - Only mock what's necessary for the test
|
||||
3. **Verify interactions** - Ensure mocks are called correctly
|
||||
4. **Use specific matchers** - Avoid `any` when possible
|
||||
5. **Clear expectations** - Make test intentions clear
|
||||
6. **Reset mocks** - Use setUp/tearDown to clean up
|
||||
7. **Test real behavior** - Verify expected outcomes, not implementation
|
||||
8. **Keep mocks simple** - Complex mocks indicate design issues
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Mock Chain
|
||||
|
||||
```dart
|
||||
when(mockService.first())
|
||||
.thenReturn(mockSecondService);
|
||||
when(mockSecondService.getData())
|
||||
.thenReturn('result');
|
||||
|
||||
final result = mockService.first().getData();
|
||||
expect(result, 'result');
|
||||
```
|
||||
|
||||
### Mock Repository
|
||||
|
||||
```dart
|
||||
class MockRepository {
|
||||
final Map<String, dynamic> _data = {};
|
||||
|
||||
T get<T>(String key) => _data[key] as T;
|
||||
void set<T>(String key, T value) => _data[key] = value;
|
||||
}
|
||||
|
||||
// Test
|
||||
test('uses mock repository', () {
|
||||
final repo = MockRepository();
|
||||
repo.set('name', 'John');
|
||||
|
||||
expect(repo.get<String>('name'), 'John');
|
||||
});
|
||||
```
|
||||
|
||||
### Mock Factory
|
||||
|
||||
```dart
|
||||
class ServiceFactory {
|
||||
static ApiService create() => RealApiService();
|
||||
}
|
||||
|
||||
class MockServiceFactory {
|
||||
ApiService? mockService;
|
||||
|
||||
ApiService create() => mockService ?? RealApiService();
|
||||
}
|
||||
|
||||
// Test
|
||||
test('uses factory mock', () {
|
||||
final factory = MockServiceFactory();
|
||||
factory.mockService = MockApiService();
|
||||
|
||||
when(factory.mockService!.getData()).thenReturn('mocked');
|
||||
|
||||
final service = factory.create();
|
||||
expect(service.getData(), 'mocked');
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,493 @@
|
||||
# Plugin Testing Guide
|
||||
|
||||
## Overview
|
||||
|
||||
Flutter plugins require special testing strategies because they include native code. This guide covers testing plugin packages with Dart, native, and integration tests.
|
||||
|
||||
## Test Types for Plugins
|
||||
|
||||
| Type | Tests | Runs On | Purpose |
|
||||
|----------------|---------------------------|------------------|-----------------------------------|
|
||||
| Dart Unit | Single classes/functions | Dart VM | Test Dart code in isolation |
|
||||
| Dart Widget | UI components | Test environment | Test widget behavior |
|
||||
| Dart Integration | Dart + Native bridge | Device/Emulator | Test full plugin functionality |
|
||||
| Native Unit | Native code | Native test env | Test native code in isolation |
|
||||
| Native UI | Native UI + Flutter UI | Device/Emulator | Test native UI interactions |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
my_plugin/
|
||||
├── lib/ # Dart code
|
||||
│ └── my_plugin.dart
|
||||
├── android/ # Android native code
|
||||
│ └── src/test/ # Android unit tests
|
||||
├── ios/ # iOS native code
|
||||
├── example/ # Example app
|
||||
│ ├── integration_test/ # Integration tests
|
||||
│ ├── ios/RunnerTests/ # iOS tests
|
||||
│ └── lib/
|
||||
├── test/ # Dart unit tests
|
||||
│ └── my_plugin_test.dart
|
||||
└── pubspec.yaml
|
||||
```
|
||||
|
||||
## Dart Unit Tests
|
||||
|
||||
### Basic Plugin Unit Test
|
||||
|
||||
```dart
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:my_plugin/my_plugin.dart';
|
||||
|
||||
void main() {
|
||||
test('plugin initializes correctly', () {
|
||||
final plugin = MyPlugin();
|
||||
expect(plugin.initialized, false);
|
||||
|
||||
plugin.initialize();
|
||||
expect(plugin.initialized, true);
|
||||
});
|
||||
|
||||
test('processes data correctly', () {
|
||||
final plugin = MyPlugin();
|
||||
final result = plugin.processData([1, 2, 3]);
|
||||
|
||||
expect(result, [2, 4, 6]);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Mocking Platform Channels in Unit Tests
|
||||
|
||||
```dart
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:my_plugin/my_plugin.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late MyPlugin plugin;
|
||||
|
||||
setUp(() {
|
||||
// Mock platform channel
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel('com.example.my_plugin'),
|
||||
(MethodCall methodCall) async {
|
||||
if (methodCall.method == 'getNativeValue') {
|
||||
return 42;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
plugin = MyPlugin();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel('com.example.my_plugin'),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('gets value from native side', () async {
|
||||
final result = await plugin.getNativeValue();
|
||||
expect(result, 42);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Dart Integration Tests
|
||||
|
||||
### Setup
|
||||
|
||||
Add to `example/pubspec.yaml`:
|
||||
|
||||
```yaml
|
||||
dev_dependencies:
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
my_plugin:
|
||||
path: ../
|
||||
```
|
||||
|
||||
### Basic Integration Test
|
||||
|
||||
```dart
|
||||
// example/integration_test/my_plugin_test.dart
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:my_plugin_example/main.dart';
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('plugin works in real app', (tester) async {
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
final plugin = MyPlugin();
|
||||
final result = await plugin.getNativeValue();
|
||||
|
||||
expect(result, greaterThan(0));
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Create Test Driver
|
||||
|
||||
```dart
|
||||
// test_driver/integration_test.dart
|
||||
import 'package:integration_test/integration_test_driver.dart';
|
||||
|
||||
Future<void> main() => integrationDriver();
|
||||
```
|
||||
|
||||
### Run Integration Tests
|
||||
|
||||
```bash
|
||||
# From example directory
|
||||
cd example
|
||||
flutter test integration_test/
|
||||
|
||||
# Or with driver
|
||||
flutter drive --target=integration_test/my_plugin_test.dart
|
||||
```
|
||||
|
||||
## Android Unit Tests
|
||||
|
||||
### Setup
|
||||
|
||||
Create test in `android/src/test/java/com/example/my_plugin/MyPluginTest.java`:
|
||||
|
||||
```java
|
||||
package com.example.my_plugin;
|
||||
|
||||
import org.junit.Test;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
public class MyPluginTest {
|
||||
|
||||
@Test
|
||||
public void testNativeMethod() {
|
||||
MyPlugin plugin = new MyPlugin();
|
||||
int result = plugin.calculate(10, 20);
|
||||
|
||||
assertEquals(30, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testStringProcessing() {
|
||||
MyPlugin plugin = new MyPlugin();
|
||||
String result = plugin.process("hello");
|
||||
|
||||
assertEquals("HELLO", result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Run Android Tests
|
||||
|
||||
```bash
|
||||
# From android directory
|
||||
cd example/android
|
||||
|
||||
# Run all tests
|
||||
./gradlew testDebugUnitTest
|
||||
|
||||
# Run specific test
|
||||
./gradlew test --tests MyPluginTest.testNativeMethod
|
||||
```
|
||||
|
||||
## iOS Unit Tests
|
||||
|
||||
### Setup
|
||||
|
||||
Create test in `example/ios/RunnerTests/RunnerTests.m`:
|
||||
|
||||
```objectivec
|
||||
#import <XCTest/XCTest.h>
|
||||
#import "MyPlugin.h"
|
||||
|
||||
@interface MyPluginTest : XCTestCase
|
||||
@end
|
||||
|
||||
@implementation MyPluginTest
|
||||
|
||||
- (void)setUp {
|
||||
[super setUp];
|
||||
// Setup code
|
||||
}
|
||||
|
||||
- (void)testNativeMethod {
|
||||
MyPlugin *plugin = [[MyPlugin alloc] init];
|
||||
NSInteger result = [plugin calculateWithA:10 b:20];
|
||||
|
||||
XCTAssertEqual(result, 30);
|
||||
}
|
||||
|
||||
- (void)testStringProcessing {
|
||||
MyPlugin *plugin = [[MyPlugin alloc] init];
|
||||
NSString *result = [plugin processString:@"hello"];
|
||||
|
||||
XCTAssertEqualObjects(result, @"HELLO");
|
||||
}
|
||||
|
||||
@end
|
||||
```
|
||||
|
||||
### Run iOS Tests
|
||||
|
||||
```bash
|
||||
# From ios directory
|
||||
cd example/ios
|
||||
|
||||
# Run tests
|
||||
xcodebuild test -workspace Runner.xcworkspace -scheme Runner -configuration Debug
|
||||
|
||||
# Or run from Xcode
|
||||
# Product > Test
|
||||
```
|
||||
|
||||
## Native UI Tests (Espresso/XCUITest)
|
||||
|
||||
### Android Espresso Test
|
||||
|
||||
```java
|
||||
// android/src/androidTest/java/com/example/my_plugin/UiTest.java
|
||||
package com.example.my_plugin;
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.rule.ActivityTestRule;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import static androidx.test.espresso.Espresso.onView;
|
||||
import static androidx.test.espresso.assertion.ViewAssertions.matches;
|
||||
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
|
||||
import static androidx.test.espresso.matcher.ViewMatchers.withText;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class UiTest {
|
||||
|
||||
@Rule
|
||||
public ActivityTestRule<MainActivity> activityRule =
|
||||
new ActivityTestRule<>(MainActivity.class);
|
||||
|
||||
@Test
|
||||
public void testPluginButton() {
|
||||
onView(withText("Plugin Button"))
|
||||
.check(matches(isDisplayed()));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### iOS XCUITest
|
||||
|
||||
```objectivec
|
||||
// example/ios/RunnerUITests/RunnerUITests.m
|
||||
#import <XCTest/XCTest.h>
|
||||
|
||||
@interface RunnerUITests : XCTestCase
|
||||
@end
|
||||
|
||||
@implementation RunnerUITests
|
||||
|
||||
- (void)setUp {
|
||||
[super setUp];
|
||||
XCUIApplication *app = [[XCUIApplication alloc] init];
|
||||
[app launch];
|
||||
}
|
||||
|
||||
- (void)testPluginButtonExists {
|
||||
XCUIApplication *app = [[XCUIApplication alloc] init];
|
||||
XCUIElement *button = app.buttons[@"Plugin Button"];
|
||||
|
||||
XCTAssertTrue(button.exists);
|
||||
}
|
||||
|
||||
- (void)testPluginButtonClick {
|
||||
XCUIApplication *app = [[XCUIApplication alloc] init];
|
||||
XCUIElement *button = app.buttons[@"Plugin Button"];
|
||||
|
||||
[button tap];
|
||||
|
||||
XCUIElement *result = app.staticTexts[@"Success"];
|
||||
XCTAssertTrue(result.exists);
|
||||
}
|
||||
|
||||
@end
|
||||
```
|
||||
|
||||
## Testing Plugin Initialization
|
||||
|
||||
### Test Plugin Registration
|
||||
|
||||
```dart
|
||||
testWidgets('plugin registers correctly', (tester) async {
|
||||
final calls = <MethodCall>[];
|
||||
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel('com.example.my_plugin'),
|
||||
(MethodCall methodCall) async {
|
||||
calls.add(methodCall);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
expect(calls.any((call) => call.method == 'initialize'), true);
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Error Handling
|
||||
|
||||
### Native Error Handling
|
||||
|
||||
```dart
|
||||
testWidgets('handles native errors', (tester) async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel('com.example.my_plugin'),
|
||||
(MethodCall methodCall) async {
|
||||
if (methodCall.method == 'failingMethod') {
|
||||
throw PlatformException(
|
||||
code: 'ERROR',
|
||||
message: 'Native error occurred',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
final plugin = MyPlugin();
|
||||
|
||||
expect(
|
||||
() => plugin.failingMethod(),
|
||||
throwsA(isA<PlatformException>()),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Platform-Specific Features
|
||||
|
||||
### Android-Specific Tests
|
||||
|
||||
```java
|
||||
@Test
|
||||
public void testAndroidOnlyFeature() {
|
||||
MyPlugin plugin = new MyPlugin();
|
||||
|
||||
// Test Android-specific API
|
||||
Context context = InstrumentationRegistry.getTargetContext();
|
||||
String deviceModel = plugin.getDeviceModel(context);
|
||||
|
||||
assertNotNull(deviceModel);
|
||||
}
|
||||
```
|
||||
|
||||
### iOS-Specific Tests
|
||||
|
||||
```objectivec
|
||||
- (void)testIOSOnlyFeature {
|
||||
MyPlugin *plugin = [[MyPlugin alloc] init];
|
||||
|
||||
// Test iOS-specific API
|
||||
NSString *deviceName = plugin.getDeviceName();
|
||||
|
||||
XCTAssertNotNil(deviceName);
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Multiple Platforms
|
||||
|
||||
### Platform Detection Test
|
||||
|
||||
```dart
|
||||
testWidgets('works on all platforms', (tester) async {
|
||||
final plugin = MyPlugin();
|
||||
final platform = await plugin.getPlatform();
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
expect(platform, contains('Android'));
|
||||
} else if (Platform.isIOS) {
|
||||
expect(platform, contains('iOS'));
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Performance
|
||||
|
||||
### Native Performance Test
|
||||
|
||||
```java
|
||||
@Test
|
||||
public void testPerformance() {
|
||||
MyPlugin plugin = new MyPlugin();
|
||||
|
||||
long startTime = System.nanoTime();
|
||||
plugin.heavyOperation();
|
||||
long endTime = System.nanoTime();
|
||||
|
||||
long duration = endTime - startTime;
|
||||
long maxDuration = 100_000_000; // 100ms
|
||||
|
||||
assertTrue("Operation too slow", duration < maxDuration);
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Test at each layer** - Dart, native, and integration
|
||||
2. **Mock platform channels** - In Dart unit tests
|
||||
3. **Test error cases** - Native failures, network issues
|
||||
4. **Use integration tests** - Test full plugin functionality
|
||||
5. **Test on real devices** - For platform-specific features
|
||||
6. **Keep tests isolated** - Each test should be independent
|
||||
7. **Test all platforms** - Android, iOS, web, desktop
|
||||
8. **Document platform limitations** - Note platform-specific behavior
|
||||
|
||||
## CI/CD for Plugins
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
```yaml
|
||||
name: Plugin Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
dart-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: subosito/flutter-action@v2
|
||||
- run: flutter test
|
||||
|
||||
android-tests:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Run Android tests
|
||||
run: |
|
||||
cd example/android
|
||||
./gradlew testDebugUnitTest
|
||||
|
||||
ios-tests:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Run iOS tests
|
||||
run: |
|
||||
cd example/ios
|
||||
xcodebuild test -workspace Runner.xcworkspace \
|
||||
-scheme Runner -configuration Debug
|
||||
```
|
||||
@@ -0,0 +1,433 @@
|
||||
# Unit Testing Guide
|
||||
|
||||
## Overview
|
||||
|
||||
Unit tests test individual functions, methods, or classes in isolation. They are the foundation of a well-tested Flutter app, providing fast feedback and high maintainability.
|
||||
|
||||
## When to Write Unit Tests
|
||||
|
||||
- Testing business logic functions
|
||||
- Validating data transformations
|
||||
- Testing state management logic (Bloc, Provider, Riverpod)
|
||||
- Mocking external services/API calls
|
||||
- Testing utility functions and helpers
|
||||
|
||||
## Test Structure
|
||||
|
||||
### Basic Test
|
||||
|
||||
```dart
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
test('description', () {
|
||||
// Arrange
|
||||
final subject = MyClass();
|
||||
|
||||
// Act
|
||||
final result = subject.myMethod();
|
||||
|
||||
// Assert
|
||||
expect(result, expectedValue);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Group Tests
|
||||
|
||||
```dart
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('Counter', () {
|
||||
late Counter counter;
|
||||
|
||||
setUp(() {
|
||||
counter = Counter();
|
||||
});
|
||||
|
||||
test('value starts at 0', () {
|
||||
expect(counter.value, 0);
|
||||
});
|
||||
|
||||
test('increment increases value', () {
|
||||
counter.increment();
|
||||
expect(counter.value, 1);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
counter.dispose();
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Testing Pure Functions
|
||||
|
||||
```dart
|
||||
// Function to test
|
||||
int add(int a, int b) => a + b;
|
||||
|
||||
// Test
|
||||
test('add returns sum of two numbers', () {
|
||||
expect(add(2, 3), 5);
|
||||
expect(add(-1, 1), 0);
|
||||
});
|
||||
```
|
||||
|
||||
### Testing State Changes
|
||||
|
||||
```dart
|
||||
class Counter {
|
||||
int _value = 0;
|
||||
int get value => _value;
|
||||
|
||||
void increment() => _value++;
|
||||
void decrement() => _value--;
|
||||
void reset() => _value = 0;
|
||||
}
|
||||
|
||||
// Tests
|
||||
group('Counter state changes', () {
|
||||
late Counter counter;
|
||||
|
||||
setUp(() => counter = Counter());
|
||||
|
||||
test('initial value is 0', () {
|
||||
expect(counter.value, 0);
|
||||
});
|
||||
|
||||
test('increment increases value by 1', () {
|
||||
counter.increment();
|
||||
expect(counter.value, 1);
|
||||
});
|
||||
|
||||
test('decrement decreases value by 1', () {
|
||||
counter.decrement();
|
||||
expect(counter.value, -1);
|
||||
});
|
||||
|
||||
test('multiple increments work correctly', () {
|
||||
counter.increment();
|
||||
counter.increment();
|
||||
counter.increment();
|
||||
expect(counter.value, 3);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Async Operations
|
||||
|
||||
```dart
|
||||
class DataService {
|
||||
Future<String> fetchData() async {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
return 'data';
|
||||
}
|
||||
}
|
||||
|
||||
// Test
|
||||
test('fetchData returns data after delay', () async {
|
||||
final service = DataService();
|
||||
final result = await service.fetchData();
|
||||
expect(result, 'data');
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Streams
|
||||
|
||||
```dart
|
||||
class CounterStream {
|
||||
final _controller = StreamController<int>();
|
||||
Stream<int> get stream => _controller.stream;
|
||||
|
||||
void increment() => _controller.sink.add(1);
|
||||
void dispose() => _controller.close();
|
||||
}
|
||||
|
||||
// Test
|
||||
test('stream emits values', () async {
|
||||
final counter = CounterStream();
|
||||
|
||||
expectLater(
|
||||
counter.stream,
|
||||
emitsInOrder([1, 1, 1]),
|
||||
);
|
||||
|
||||
counter.increment();
|
||||
counter.increment();
|
||||
counter.increment();
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
counter.dispose();
|
||||
});
|
||||
```
|
||||
|
||||
## Matchers
|
||||
|
||||
### Common Matchers
|
||||
|
||||
```dart
|
||||
// Equality
|
||||
expect(actual, equals(expected));
|
||||
|
||||
// Not equal
|
||||
expect(actual, isNot(equals(expected)));
|
||||
|
||||
// Null checks
|
||||
expect(value, isNull);
|
||||
expect(value, isNotNull);
|
||||
|
||||
// Numeric comparisons
|
||||
expect(5, greaterThan(3));
|
||||
expect(5, lessThan(10));
|
||||
expect(5, greaterThanOrEqualTo(5));
|
||||
expect(5, lessThanOrEqualTo(5));
|
||||
|
||||
// Type checking
|
||||
expect(obj, isA<String>());
|
||||
expect(obj, isNot(isA<int>()));
|
||||
|
||||
// String matching
|
||||
expect(text, contains('substring'));
|
||||
expect(text, startsWith('prefix'));
|
||||
expect(text, endsWith('suffix'));
|
||||
expect(text, matches(RegExp(r'\d+')));
|
||||
|
||||
// Lists
|
||||
expect(list, isEmpty);
|
||||
expect(list, isNotEmpty);
|
||||
expect(list, hasLength(3));
|
||||
expect(list, contains(item));
|
||||
expect(list, orderedEquals([1, 2, 3]));
|
||||
```
|
||||
|
||||
### Custom Matchers
|
||||
|
||||
```dart
|
||||
import 'package:test/test.dart';
|
||||
|
||||
class HasLength extends Matcher {
|
||||
final int expectedLength;
|
||||
HasLength(this.expectedLength);
|
||||
|
||||
@override
|
||||
bool matches(item, Map matchState) {
|
||||
return (item as List).length == expectedLength;
|
||||
}
|
||||
|
||||
@override
|
||||
Description describe(Description description) {
|
||||
return description.add('has length $expectedLength');
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
test('list has custom length', () {
|
||||
final list = [1, 2, 3];
|
||||
expect(list, HasLength(3));
|
||||
});
|
||||
```
|
||||
|
||||
## Exception Testing
|
||||
|
||||
```dart
|
||||
class Calculator {
|
||||
int divide(int a, int b) {
|
||||
if (b == 0) throw ArgumentError('Cannot divide by zero');
|
||||
return a ~/ b;
|
||||
}
|
||||
}
|
||||
|
||||
// Test
|
||||
test('divide throws when dividing by zero', () {
|
||||
final calculator = Calculator();
|
||||
expect(
|
||||
() => calculator.divide(10, 0),
|
||||
throwsArgumentError,
|
||||
);
|
||||
});
|
||||
|
||||
test('divide throws with specific message', () {
|
||||
final calculator = Calculator();
|
||||
expect(
|
||||
() => calculator.divide(10, 0),
|
||||
throwsA(isA<ArgumentError>()
|
||||
.having((e) => e.message, 'message', contains('zero'))),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## Mocking
|
||||
|
||||
### Using Mockito
|
||||
|
||||
```dart
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import 'user_service_test.mocks.dart';
|
||||
|
||||
@GenerateMocks([ApiClient])
|
||||
void main() {
|
||||
group('UserService', () {
|
||||
late MockApiClient mockApiClient;
|
||||
late UserService userService;
|
||||
|
||||
setUp(() {
|
||||
mockApiClient = MockApiClient();
|
||||
userService = UserService(mockApiClient);
|
||||
});
|
||||
|
||||
test('fetches user data', () async {
|
||||
when(mockApiClient.getUser('123'))
|
||||
.thenAnswer((_) async => User(id: '123', name: 'John'));
|
||||
|
||||
final user = await userService.getUser('123');
|
||||
|
||||
expect(user.name, 'John');
|
||||
verify(mockApiClient.getUser('123')).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Generate the mock file with:
|
||||
|
||||
```bash
|
||||
dart run build_runner build
|
||||
```
|
||||
|
||||
### Manual Mocking
|
||||
|
||||
```dart
|
||||
class MockStorage implements Storage {
|
||||
String? savedData;
|
||||
|
||||
@override
|
||||
void save(String data) {
|
||||
savedData = data;
|
||||
}
|
||||
|
||||
@override
|
||||
String? load() => savedData;
|
||||
}
|
||||
|
||||
// Test
|
||||
test('saves and loads data', () {
|
||||
final mockStorage = MockStorage();
|
||||
final service = DataService(mockStorage);
|
||||
|
||||
service.saveData('test');
|
||||
expect(mockStorage.savedData, 'test');
|
||||
|
||||
final loaded = service.loadData();
|
||||
expect(loaded, 'test');
|
||||
});
|
||||
```
|
||||
|
||||
## Testing with Fake Clock
|
||||
|
||||
```dart
|
||||
import 'package:clock/clock.dart';
|
||||
|
||||
test('time-based operations', () {
|
||||
withClock(Clock(() => DateTime(2024, 1, 1)), () {
|
||||
final timestamp = DateTime.now();
|
||||
expect(timestamp.year, 2024);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Test Organization
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
lib/
|
||||
counter.dart
|
||||
test/
|
||||
counter/
|
||||
counter_test.dart
|
||||
counter_value_test.dart
|
||||
counter_operations_test.dart
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- Test files: `*_test.dart`
|
||||
- Test groups: Group related functionality
|
||||
- Test names: Describe what and why
|
||||
|
||||
```dart
|
||||
// Good
|
||||
test('value increments when increment is called', () {});
|
||||
|
||||
// Avoid
|
||||
test('increment', () {});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Arrange-Act-Assert** - Structure tests with clear sections
|
||||
2. **One assertion per test** - When possible, keep tests focused
|
||||
3. **Descriptive names** - Explain what and why, not just what
|
||||
4. **Avoid test interdependence** - Each test should be independent
|
||||
5. **Use setUp/tearDown** - Avoid code duplication
|
||||
6. **Test edge cases** - Test boundaries, nulls, and errors
|
||||
7. **Keep tests fast** - Avoid slow operations in unit tests
|
||||
8. **Mock external dependencies** - Tests should be deterministic
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Testing Implementation Details
|
||||
|
||||
```dart
|
||||
// Bad - tests implementation
|
||||
test('counter calls _update', () {
|
||||
verify(counter._update()).called(1);
|
||||
});
|
||||
|
||||
// Good - tests behavior
|
||||
test('counter value increases after increment', () {
|
||||
expect(counter.value, 1);
|
||||
});
|
||||
```
|
||||
|
||||
### Fragile Tests
|
||||
|
||||
```dart
|
||||
// Bad - depends on exact timing
|
||||
test('completes within 100ms', () async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
await operation();
|
||||
expect(stopwatch.elapsedMilliseconds, lessThan(100));
|
||||
});
|
||||
|
||||
// Good - tests completion
|
||||
test('completes successfully', () async {
|
||||
await operation();
|
||||
// operation completed without error
|
||||
});
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
flutter test
|
||||
|
||||
# Run specific file
|
||||
flutter test test/counter/counter_test.dart
|
||||
|
||||
# Run with coverage
|
||||
flutter test --coverage
|
||||
|
||||
# Run verbose
|
||||
flutter test --verbose
|
||||
|
||||
# Run specific test
|
||||
flutter test --name "value increments"
|
||||
```
|
||||
@@ -0,0 +1,670 @@
|
||||
# Widget Testing Guide
|
||||
|
||||
## Overview
|
||||
|
||||
Widget tests (component tests) verify that widgets render correctly and interact as expected. They run in a test environment that simulates widget lifecycle without a full UI system.
|
||||
|
||||
## When to Write Widget Tests
|
||||
|
||||
- Verifying widget rendering
|
||||
- Testing user interactions (taps, drags, scrolling)
|
||||
- Validating widget state changes
|
||||
- Testing different orientations and screen sizes
|
||||
- Testing form inputs and validation
|
||||
|
||||
## Test Structure
|
||||
|
||||
### Basic Widget Test
|
||||
|
||||
```dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('widget description', (tester) async {
|
||||
// Build widget
|
||||
await tester.pumpWidget(const MyWidget());
|
||||
|
||||
// Find and verify
|
||||
expect(find.text('Hello'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Using MaterialApp
|
||||
|
||||
```dart
|
||||
testWidgets('widget with MaterialApp', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MyWidget(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byType(MyWidget), findsOneWidget);
|
||||
});
|
||||
```
|
||||
|
||||
## Finding Widgets
|
||||
|
||||
### Common Finders
|
||||
|
||||
```dart
|
||||
// By text
|
||||
find.text('Hello')
|
||||
find.textContaining('ello')
|
||||
find.textRegExp(RegExp(r'\d+'))
|
||||
|
||||
// By widget type
|
||||
find.byType(Text)
|
||||
find.byType(ElevatedButton)
|
||||
find.byType(MyWidget)
|
||||
|
||||
// By key
|
||||
find.byKey(const Key('my-button'))
|
||||
find.byKey(const ValueKey('counter'))
|
||||
|
||||
// By icon
|
||||
find.byIcon(Icons.add)
|
||||
|
||||
// By widget instance
|
||||
find.byWidget(myWidgetInstance)
|
||||
|
||||
// By specific widget ancestor
|
||||
find.ancestor(
|
||||
of: find.text('Child'),
|
||||
matching: find.byType(Column),
|
||||
)
|
||||
```
|
||||
|
||||
### Finder Matchers
|
||||
|
||||
```dart
|
||||
// Find exactly one
|
||||
expect(find.text('Hello'), findsOneWidget);
|
||||
|
||||
// Find multiple
|
||||
expect(find.text('Hello'), findsWidgets);
|
||||
|
||||
// Find nothing
|
||||
expect(find.text('Missing'), findsNothing);
|
||||
|
||||
// Find at least N
|
||||
expect(find.text('Item'), findsNWidgets(3));
|
||||
```
|
||||
|
||||
## User Interactions
|
||||
|
||||
### Tapping
|
||||
|
||||
```dart
|
||||
// Tap a widget
|
||||
await tester.tap(find.byType(ElevatedButton));
|
||||
|
||||
// Trigger a frame
|
||||
await tester.pump();
|
||||
|
||||
// Wait for all animations
|
||||
await tester.pumpAndSettle();
|
||||
```
|
||||
|
||||
### Dragging
|
||||
|
||||
```dart
|
||||
// Drag a widget
|
||||
await tester.drag(
|
||||
find.byType(MyWidget),
|
||||
const Offset(0, -300),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
```
|
||||
|
||||
### Entering Text
|
||||
|
||||
```dart
|
||||
// Enter text into TextField
|
||||
await tester.enterText(
|
||||
find.byType(TextField),
|
||||
'Hello World',
|
||||
);
|
||||
|
||||
// Tap to focus, then enter
|
||||
await tester.tap(find.byType(TextField));
|
||||
await tester.enterText(find.byType(TextField), 'Text');
|
||||
|
||||
// Enter by key
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('email-field')),
|
||||
'test@example.com',
|
||||
);
|
||||
```
|
||||
|
||||
### Scrolling
|
||||
|
||||
```dart
|
||||
// Scroll a ListView
|
||||
await tester.fling(
|
||||
find.byType(ListView),
|
||||
const Offset(0, -500),
|
||||
10000,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Scroll until widget is visible
|
||||
await tester.scrollUntilVisible(
|
||||
find.text('Target Item'),
|
||||
500.0,
|
||||
);
|
||||
```
|
||||
|
||||
### Long Press
|
||||
|
||||
```dart
|
||||
await tester.longPress(find.byType(IconButton));
|
||||
await tester.pumpAndSettle();
|
||||
```
|
||||
|
||||
## Testing Widget Properties
|
||||
|
||||
### Text Content
|
||||
|
||||
```dart
|
||||
testWidgets('displays correct text', (tester) async {
|
||||
await tester.pumpWidget(const MyWidget(text: 'Hello'));
|
||||
|
||||
expect(find.text('Hello'), findsOneWidget);
|
||||
expect(find.text('Goodbye'), findsNothing);
|
||||
});
|
||||
```
|
||||
|
||||
### Widget State
|
||||
|
||||
```dart
|
||||
testWidgets('updates state on button press', (tester) async {
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
// Initial state
|
||||
expect(find.text('Count: 0'), findsOneWidget);
|
||||
|
||||
// Tap increment button
|
||||
await tester.tap(find.byKey(const Key('increment')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Updated state
|
||||
expect(find.text('Count: 1'), findsOneWidget);
|
||||
});
|
||||
```
|
||||
|
||||
### Conditional Rendering
|
||||
|
||||
```dart
|
||||
testWidgets('shows loading indicator when loading', (tester) async {
|
||||
await tester.pumpWidget(MyWidget(isLoading: true));
|
||||
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
expect(find.byType(ElevatedButton), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('shows button when not loading', (tester) async {
|
||||
await tester.pumpWidget(MyWidget(isLoading: false));
|
||||
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
expect(find.byType(ElevatedButton), findsOneWidget);
|
||||
});
|
||||
```
|
||||
|
||||
## Testing with Arguments
|
||||
|
||||
```dart
|
||||
testWidgets('receives and displays title', (tester) async {
|
||||
const title = 'My Title';
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: MyWidget(title: title),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text(title), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('receives and uses callback', (tester) async {
|
||||
bool callbackCalled = false;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: MyWidget(
|
||||
onPressed: () => callbackCalled = true,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byType(ElevatedButton));
|
||||
expect(callbackCalled, true);
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Orientation
|
||||
|
||||
```dart
|
||||
testWidgets('widget in portrait mode', (tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(400, 800));
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
// Verify portrait layout
|
||||
expect(find.byType(Column), findsOneWidget);
|
||||
|
||||
// Clean up
|
||||
addTearDown(tester.binding.setSurfaceSize(null));
|
||||
});
|
||||
|
||||
testWidgets('widget in landscape mode', (tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(800, 400));
|
||||
await tester.pumpWidget(const MyApp());
|
||||
|
||||
// Verify landscape layout
|
||||
expect(find.byType(Row), findsOneWidget);
|
||||
|
||||
// Clean up
|
||||
addTearDown(tester.binding.setSurfaceSize(null));
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Scrollable Widgets
|
||||
|
||||
### ListView Scrolling
|
||||
|
||||
```dart
|
||||
testWidgets('scrolling in ListView', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: ListView.builder(
|
||||
itemCount: 100,
|
||||
itemBuilder: (context, index) => ListTile(
|
||||
title: Text('Item $index'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Initial item visible
|
||||
expect(find.text('Item 0'), findsOneWidget);
|
||||
expect(find.text('Item 50'), findsNothing);
|
||||
|
||||
// Scroll down
|
||||
await tester.fling(
|
||||
find.byType(ListView),
|
||||
const Offset(0, -5000),
|
||||
10000,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Later item now visible
|
||||
expect(find.text('Item 50'), findsOneWidget);
|
||||
});
|
||||
```
|
||||
|
||||
### Scroll Until Visible
|
||||
|
||||
```dart
|
||||
testWidgets('scroll until specific item is visible', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: ListView.builder(
|
||||
itemCount: 100,
|
||||
itemBuilder: (context, index) => ListTile(
|
||||
key: ValueKey('item-$index'),
|
||||
title: Text('Item $index'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Scroll until item 75 is visible
|
||||
await tester.scrollUntilVisible(
|
||||
find.byKey(const ValueKey('item-75')),
|
||||
500.0,
|
||||
);
|
||||
|
||||
expect(find.text('Item 75'), findsOneWidget);
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Forms
|
||||
|
||||
### TextField Validation
|
||||
|
||||
```dart
|
||||
testWidgets('validates email field', (tester) async {
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
children: [
|
||||
TextFormField(
|
||||
key: const Key('email'),
|
||||
validator: (value) {
|
||||
if (value == null || !value.contains('@')) {
|
||||
return 'Invalid email';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
ElevatedButton(
|
||||
key: const Key('submit'),
|
||||
onPressed: () => formKey.currentState!.validate(),
|
||||
child: const Text('Submit'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Enter invalid email
|
||||
await tester.enterText(find.byKey(const Key('email')), 'invalid');
|
||||
await tester.tap(find.byKey(const Key('submit')));
|
||||
await tester.pump();
|
||||
|
||||
// Should show error
|
||||
expect(find.text('Invalid email'), findsOneWidget);
|
||||
|
||||
// Enter valid email
|
||||
await tester.enterText(find.byKey(const Key('email')), 'test@example.com');
|
||||
await tester.tap(find.byKey(const Key('submit')));
|
||||
await tester.pump();
|
||||
|
||||
// Should hide error
|
||||
expect(find.text('Invalid email'), findsNothing);
|
||||
});
|
||||
```
|
||||
|
||||
### Multiple Form Fields
|
||||
|
||||
```dart
|
||||
testWidgets('form with multiple fields', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Form(
|
||||
child: Column(
|
||||
children: [
|
||||
TextFormField(
|
||||
key: const Key('name'),
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
),
|
||||
TextFormField(
|
||||
key: const Key('email'),
|
||||
decoration: const InputDecoration(labelText: 'Email'),
|
||||
),
|
||||
ElevatedButton(
|
||||
key: const Key('submit'),
|
||||
onPressed: () {},
|
||||
child: const Text('Submit'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Fill form
|
||||
await tester.enterText(find.byKey(const Key('name')), 'John Doe');
|
||||
await tester.enterText(find.byKey(const Key('email')), 'john@example.com');
|
||||
|
||||
// Submit
|
||||
await tester.tap(find.byKey(const Key('submit')));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Animations
|
||||
|
||||
```dart
|
||||
testWidgets('animation plays correctly', (tester) async {
|
||||
await tester.pumpWidget(const AnimatedWidget());
|
||||
|
||||
// Initial state
|
||||
expect(find.byType(Opacity), findsOneWidget);
|
||||
|
||||
// Trigger animation
|
||||
await tester.tap(find.byType(ElevatedButton));
|
||||
|
||||
// Pump animation frames
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
// Animation complete
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify final state
|
||||
final opacity = tester.widget<Opacity>(find.byType(Opacity));
|
||||
expect(opacity.opacity, 0.0);
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Custom Widgets
|
||||
|
||||
### Testing InheritedWidgets
|
||||
|
||||
```dart
|
||||
class MyInheritedWidget extends InheritedWidget {
|
||||
final String data;
|
||||
|
||||
const MyInheritedWidget({
|
||||
super.key,
|
||||
required this.data,
|
||||
required Widget child,
|
||||
}) : super(child: child);
|
||||
|
||||
static MyInheritedWidget of(BuildContext context) {
|
||||
return context.dependOnInheritedWidgetOfExactType<MyInheritedWidget>()!;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(MyInheritedWidget oldWidget) =>
|
||||
data != oldWidget.data;
|
||||
}
|
||||
|
||||
testWidgets('inherited widget provides data', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MyInheritedWidget(
|
||||
data: 'Test Data',
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
return Text(MyInheritedWidget.of(context).data);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Test Data'), findsOneWidget);
|
||||
});
|
||||
```
|
||||
|
||||
### Testing StatefulWidget
|
||||
|
||||
```dart
|
||||
class CounterWidget extends StatefulWidget {
|
||||
@override
|
||||
State<CounterWidget> createState() => _CounterWidgetState();
|
||||
}
|
||||
|
||||
class _CounterWidgetState extends State<CounterWidget> {
|
||||
int _count = 0;
|
||||
|
||||
void _increment() => setState(() => _count++);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Text('Count: $_count'),
|
||||
ElevatedButton(
|
||||
key: const Key('increment'),
|
||||
onPressed: _increment,
|
||||
child: const Text('Increment'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('CounterWidget increments correctly', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: CounterWidget(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Initial count
|
||||
expect(find.text('Count: 0'), findsOneWidget);
|
||||
|
||||
// Increment
|
||||
await tester.tap(find.byKey(const Key('increment')));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Count: 1'), findsOneWidget);
|
||||
});
|
||||
```
|
||||
|
||||
## Testing with Mock Data
|
||||
|
||||
```dart
|
||||
class ProductList extends StatelessWidget {
|
||||
final List<Product> products;
|
||||
|
||||
const ProductList({super.key, required this.products});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.builder(
|
||||
itemCount: products.length,
|
||||
itemBuilder: (context, index) => ListTile(
|
||||
title: Text(products[index].name),
|
||||
subtitle: Text('\$${products[index].price}'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('displays list of products', (tester) async {
|
||||
final products = [
|
||||
Product(name: 'Product 1', price: 10.99),
|
||||
Product(name: 'Product 2', price: 20.99),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: ProductList(products: products),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Product 1'), findsOneWidget);
|
||||
expect(find.text('\$10.99'), findsOneWidget);
|
||||
expect(find.text('Product 2'), findsOneWidget);
|
||||
expect(find.text('\$20.99'), findsOneWidget);
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Accessibility
|
||||
|
||||
```dart
|
||||
testWidgets('widget has proper semantics', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Semantics(
|
||||
label: 'Submit button',
|
||||
button: true,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {},
|
||||
child: const Text('Submit'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final semantics = tester.getSemantics(find.byType(ElevatedButton));
|
||||
expect(semantics.label, 'Submit button');
|
||||
expect(semantics.hasAction(SemanticsAction.tap), true);
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use descriptive test names** - Explain what and why
|
||||
2. **Keep tests independent** - Each test should be standalone
|
||||
3. **Use pumpAndSettle()** - Wait for all animations
|
||||
4. **Test user behavior, not implementation** - Focus on what users see and do
|
||||
5. **Group related tests** - Use `group()` for organization
|
||||
6. **Test edge cases** - Empty lists, null values, errors
|
||||
7. **Use keys for widgets** - Makes finding easier and more reliable
|
||||
8. **Test accessibility** - Verify semantic labels and actions
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Testing Navigation
|
||||
|
||||
```dart
|
||||
testWidgets('navigates to detail screen', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
initialRoute: '/',
|
||||
routes: {
|
||||
'/': (context) => const HomeScreen(),
|
||||
'/detail': (context) => const DetailScreen(),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Tap navigation button
|
||||
await tester.tap(find.text('Go to Detail'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify navigation
|
||||
expect(find.text('Detail Screen'), findsOneWidget);
|
||||
expect(find.text('Home Screen'), findsNothing);
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Loading States
|
||||
|
||||
```dart
|
||||
testWidgets('shows loading then content', (tester) async {
|
||||
bool isLoading = true;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: const Text('Content Loaded');
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
|
||||
// Simulate data load
|
||||
isLoading = false;
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
expect(find.text('Content Loaded'), findsOneWidget);
|
||||
});
|
||||
```
|
||||
Reference in New Issue
Block a user