# Widget Testing en Flutter
El widget testing verifica que los widgets se rendericen y comporten correctamente en respuesta a interacciones
del usuario.
## ¿Qué es Widget Testing?
- Prueba widgets individuales o árboles de widgets pequeños
- Más rápido que integration tests
- Más lento que unit tests
- Verifica UI y comportamiento
## Configuración
```yaml
# pubspec.yaml
dev_dependencies:
flutter_test:
sdk: flutter
```
## Anatomy de un Widget Test
```mermaid
graph LR
A[Pump Widget] -->|Build & Render| B[Find Widgets]
B -->|Locate elements| C[Interact]
C -->|Tap/Enter Text| D[Verify]
D -->|Expectations| E[Result]
style A fill:#e1f5fe,stroke:#01579b
style B fill:#fff3e0,stroke:#e65100
style C fill:#f3e5f5,stroke:#4a148c
style D fill:#e8f5e9,stroke:#1b5e20
```
```dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test Widget('CounterWidget displays initial count', (WidgetTester tester) async {
// 1. Build widget
await tester.pumpWidget(
MaterialApp(
home: CounterWidget(initialCount: 0),
),
);
// 2. Find elements
final countFinder = find.text('0');
// 3. Verify
expect(countFinder, findsOneWidget);
});
}
```
## Finders
### Buscar Widgets
```dart
void main() {
testWidgets('Finding widgets', (tester) async {
await tester.pumpWidget(MyApp());
// Por texto
expect(find.text('Hello'), findsOneWidget);
expect(find.textContaining('Hel'), findsOneWidget);
// Por tipo
expect(find.byType(ElevatedButton), findsOneWidget);
expect(find.byType(TextField), findsNWidgets(2));
// Por key
expect(find.byKey(Key('submit-button')), findsOneWidget);
// Por icon
expect(find.byIcon(Icons.add), findsOneWidget);
// Por widget instance
final myWidget = Text('Test');
expect(find.byWidget(myWidget), findsOneWidget);
// Combinados
expect(
find.descendant(
of: find.byType(AppBar),
matching: find.text('Title'),
),
findsOneWidget,
);
});
}
```
### Matchers para Finders
```mermaid
graph TD
subgraph Finders
F1[find.text]
F2[find.byType]
F3[find.byKey]
end
subgraph Matchers
M1[findsOneWidget]
M2[findsNothing]
M3[findsNWidgets]
end
F1 -->|Verifica cantidad| M1
F2 -->|Verifica cantidad| M2
F3 -->|Verifica cantidad| M3
style Finders fill:#e3f2fd,stroke:#2196f3
style Matchers fill:#f1f8e9,stroke:#8bc34a
```
```dart
testWidgets('Finder matchers', (tester) async {
await tester.pumpWidget(MyApp());
expect(find.text('Login'), findsOneWidget); // Exactamente 1
expect(find.text('Missing'), findsNothing); // 0
expect(find.byType(ListTile), findsWidgets); // Al menos 1
expect(find.byType(Card), findsNWidgets(3)); // Exactamente 3
expect(find.byType(Text), findsAtLeastNWidgets(1)); // Al menos N
});
```
## Interacciones
```dart
testWidgets('User interactions', (tester) async {
await tester.pumpWidget(
MaterialApp(home: LoginPage()),
);
// Tap en un botón
await tester.tap(find.byType(ElevatedButton));
await tester.pump(); // Rebuild after tap
// Long press
await tester.longPress(find.text('Menu'));
await tester.pump();
// Enter text
await tester.enterText(find.byType(TextField), '[email protected]');
await tester.pump();
// Drag/Scroll
await tester.drag(find.byType(ListView), Offset(0, -200));
await tester.pump();
// Fling (scroll rápido)
await tester.fling(find.byType(ListView), Offset(0, -500), 10000);
await tester.pumpAndSettle(); // Espera a que termine la animación
});
```
## Pump Methods
```dart
testWidgets('Pump methods', (tester) async {
await tester.pumpWidget(MyApp());
// pump() - Un solo frame
await tester.pump();
// pump(duration) - Avanza el reloj
await tester.pump(Duration(seconds: 1));
// pumpAndSettle() - Espera hasta que no haya más frames
await tester.pumpAndSettle();
// pumpAndSettle(timeout) - Con timeout
await tester.pumpAndSettle(Duration(seconds: 5));
// runAsync() - Para operaciones asíncronas reales
await tester.runAsync(() async {
await Future.delayed(Duration(seconds: 2));
});
});
```
## Ejemplo Completo: Login Form
```dart
// lib/widgets/login_form.dart
class LoginForm extends StatefulWidget {
final void Function(String email, String password) onSubmit;
const LoginForm({required this.onSubmit});
@override
_LoginFormState createState() => _LoginFormState();
}
class _LoginFormState extends State {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
String? _errorMessage;
void _submit() {
final email = _emailController.text;
final password = _passwordController.text;
if (email.isEmpty || password.isEmpty) {
setState(() {
_errorMessage = 'Please fill all fields';
});
return;
}
widget.onSubmit(email, password);
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
key: Key('email-field'),
controller: _emailController,
decoration: InputDecoration(labelText: 'Email'),
),
TextField(
key: Key('password-field'),
controller: _passwordController,
decoration: InputDecoration(labelText: 'Password'),
obscureText: true,
),
if (_errorMessage != null)
Text(_errorMessage!, style: TextStyle(color: Colors.red)),
ElevatedButton(
key: Key('submit-button'),
onPressed: _submit,
child: Text('Login'),
),
],
);
}
}
// test/widgets/login_form_test.dart
void main() {
group('LoginForm Widget', () {
testWidgets('renders email and password fields', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: LoginForm(onSubmit: (_, __) {}),
),
),
);
expect(find.byKey(Key('email-field')), findsOneWidget);
expect(find.byKey(Key('password-field')), findsOneWidget);
expect(find.byKey(Key('submit-button')), findsOneWidget);
});
testWidgets('shows error when fields are empty', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: LoginForm(onSubmit: (_, __) {}),
),
),
);
// Tap submit sin llenar campos
await tester.tap(find.byKey(Key('submit-button')));
await tester.pump();
expect(find.text('Please fill all fields'), findsOneWidget);
});
testWidgets('calls onSubmit with correct values', (tester) async {
String? submittedEmail;
String? submittedPassword;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: LoginForm(
onSubmit: (email, password) {
submittedEmail = email;
submittedPassword = password;
},
),
),
),
);
// Llenar campos
await tester.enterText(
find.byKey(Key('email-field')),
'[email protected]',
);
await tester.enterText(
find.byKey(Key('password-field')),
'password123',
);
// Submit
await tester.tap(find.byKey(Key('submit-button')));
await tester.pump();
expect(submittedEmail, '[email protected]');
expect(submittedPassword, 'password123');
expect(find.text('Please fill all fields'), findsNothing);
});
});
}
```
## Testear con BLoC/Provider
```dart
testWidgets('Counter increments with BLoC', (tester) async {
final counterBloc = CounterBloc();
await tester.pumpWidget(
MaterialApp(
home: BlocProvider.value(
value: counterBloc,
child: CounterPage(),
),
),
);
// Verificar estado inicial
expect(find.text('0'), findsOneWidget);
// Tap increment button
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verificar nuevo estado
expect(find.text('1'), findsOneWidget);
expect(find.text('0'), findsNothing);
counterBloc.close();
});
```
## Testear Navegación
```dart
testWidgets('navigates to detail page on tap', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: UserListPage(),
routes: {
'/detail': (context) => UserDetailPage(),
},
),
);
// Tap en un item de la lista
await tester.tap(find.byType(ListTile).first);
await tester.pumpAndSettle();
// Verificar que navegó
expect(find.byType(UserDetailPage), findsOneWidget);
// Verificar back button
await tester.tap(find.byType(BackButton));
await tester.pumpAndSettle();
expect(find.byType(UserListPage), findsOneWidget);
});
```
## Testear Animaciones
```dart
testWidgets('animation completes correctly', (tester) async {
await tester.pumpWidget(
MaterialApp(home: AnimatedWidget()),
);
// Trigger animation
await tester.tap(find.text('Animate'));
// Avanzar animación parcialmente
await tester.pump(Duration(milliseconds: 100));
// Verificar estado intermedio
final container = tester.widget(find.byType(Container));
expect((container.constraints?.maxHeight ?? 0) > 0, true);
expect((container.constraints?.maxHeight ?? 200) < 200, true); // Completar animación await
tester.pumpAndSettle(); // Verificar estado final final finalContainer=tester.widget
(find.byType(Container));
expect(finalContainer.constraints?.maxHeight, 200);
});
```
## Testear Gestos
```dart
testWidgets('dismissible swipe works', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Dismissible(
key: Key('item'),
child: ListTile(title: Text('Swipe me')),
onDismissed: (_) {},
),
),
),
);
expect(find.text('Swipe me'), findsOneWidget);
// Swipe para dismissar
await tester.drag(
find.byType(Dismissible),
Offset(500, 0), // Swipe a la derecha
);
await tester.pumpAndSettle();
expect(find.text('Swipe me'), findsNothing);
});
```
## Golden Tests (Screenshot Tests)
```dart
testWidgets('login form golden test', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: LoginForm(onSubmit: (_, __) {}),
),
),
);
// Comparar con imagen de referencia
await expectLater(
find.byType(LoginForm),
matchesGoldenFile('goldens/login_form.png'),
);
});
// Generar/actualizar golden files:
// flutter test --update-goldens
```
## Mejores Prácticas
### ✅ Hacer
```dart
// Keys para encontrar widgets fácilmente
TextField(key: Key('email-field'));
// setUp para código común
setUp(() {
// Inicialización común
});
// Usar pumpAndSettle para animaciones
await tester.pumpAndSettle();
// Extraer helpers
Future pumpLoginPage(WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(home: LoginPage()),
);
}
```
### ❌ Evitar
```dart
// ❌ find.text() con textos dinámicos
expect(find.text('${DateTime.now()}'), findsOneWidget);
// ❌ Delays arbitrarios
await Future.delayed(Duration(seconds: 2));
// Usar: await tester.pumpAndSettle();
// ❌ Tests que dependen de tamaño de pantalla específico
expect(tester.getSize(find.byType(Container)).width, 375);
```
## Debugging
```dart
testWidgets('debug widget tree', (tester) async {
await tester.pumpWidget(MyApp());
// Imprimir árbol de widgets
debugDumpApp();
// Imprimir árbol de renderizado
debugDumpRenderTree();
// Imprimir detalles de un widget
final widget = tester.widget(find.text('Hello'));
print(widget);
});
```
## Conclusión
Widget testing es esencial para:
- ✅ **Verificar UI correcta**
- ✅ **Testear interacciones del usuario**
- ✅ **Detectar regresiones visuales**
- ✅ **Documentar comportamiento esperado**
> "Si no lo puedes testear, probablemente no lo puedas mantener"
Este apartado profundiza en los conceptos clave, proporcionando ejemplos prácticos y mejores prácticas para su aplicación en proyectos reales.
Al comprender estos detalles, podrás diseñar soluciones más robustas, mantenibles y escalables en tus aplicaciones Flutter y Dart.