Módulo 01: Fundamentos y Buenas Prácticas

Widget Testing

Testing Strategy

# 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.