671 lines
14 KiB
Markdown
671 lines
14 KiB
Markdown
# 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);
|
|
});
|
|
```
|