Módulo 01: Fundamentos y Buenas Prácticas

Unit Testing

Testing Strategy

# Unit Testing en Flutter El unit testing es la prueba de unidades individuales de código (funciones, métodos, clases) de forma aislada. ## ¿Por qué Unit Testing? - ✅ **Detecta errores temprano** - ✅ **Documenta el comportamiento esperado** - ✅ **Facilita refactoring seguro** - ✅ **Mejora el diseño del código** - ✅ **Reduce costos de mantenimiento** ## Configuración ```yaml # pubspec.yaml dev_dependencies: flutter_test: sdk: flutter mockito: ^5.4.0 build_runner: ^2.4.0 ``` ## Anatomía de un Unit Test El patrón AAA (Arrange-Act-Assert) es el estándar para escribir tests legibles. ```mermaid graph LR A[Arrange] -->|Preparar datos y mocks| B[Act] B -->|Ejecutar acción| C[Assert] C -->|Verificar resultado| D[Resultado] style A fill:#e1f5fe,stroke:#01579b style B fill:#fff3e0,stroke:#e65100 style C fill:#e8f5e9,stroke:#1b5e20 ``` ```dart import 'package:flutter_test/flutter_test.dart'; void main() { // Arrange, Act, Assert test('Calculator adds two numbers correctly', () { // Arrange - Preparar datos de prueba final calculator = Calculator(); const a = 5; const b = 3; // Act - Ejecutar la acción final result = calculator.add(a, b); // Assert - Verificar el resultado expect(result, 8); }); } ``` ## Testear Funciones Puras ```dart // lib/utils/validators.dart class Validators { static bool isValidEmail(String email) { final regex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$'); return regex.hasMatch(email); } static bool isValidPassword(String password) { return password.length >= 8 && password.contains(RegExp(r'[A-Z]')) && password.contains(RegExp(r'[0-9]')); } } // test/utils/validators_test.dart import 'package:flutter_test/flutter_test.dart'; void main() { group('Validators', () { group('isValidEmail', () { test('should return true for valid email', () { expect(Validators.isValidEmail('[email protected]'), true); expect(Validators.isValidEmail('[email protected]'), true); }); test('should return false for invalid email', () { expect(Validators.isValidEmail('invalidemail'), false); expect(Validators.isValidEmail('test@'), false); expect(Validators.isValidEmail('@example.com'), false); expect(Validators.isValidEmail(''), false); }); }); group('isValidPassword', () { test('should return true for valid password', () { expect(Validators.isValidPassword('Password123'), true); expect(Validators.isValidPassword('MyP@ssw0rd'), true); }); test('should return false for weak password', () { expect(Validators.isValidPassword('short1A'), false); // Muy corto expect(Validators.isValidPassword('nouppercase123'), false); // Sin mayúsculas expect(Validators.isValidPassword('NoNumbers'), false); // Sin números }); }); }); } ``` ## Testear Clases con Dependencias (Mocking) ```dart // lib/repositories/user_repository.dart abstract class UserRepository { Future getUser(String id); Future saveUser(User user); } // lib/services/user_service.dart class UserService { final UserRepository repository; UserService(this.repository); Future getUserById(String id) async { if (id.isEmpty) { throw ArgumentError('User ID cannot be empty'); } return await repository.getUser(id); } Future isAdult(String userId) async { final user = await getUserById(userId); return user.age >= 18; } } // test/services/user_service_test.dart import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'user_service_test.mocks.dart'; @GenerateMocks([UserRepository]) void main() { late UserService userService; late MockUserRepository mockRepository; setUp(() { mockRepository = MockUserRepository(); userService = UserService(mockRepository); }); group('UserService', () { group('getUserById', () { const tUserId = '123'; final tUser = User(id: tUserId, name: 'Test User', age: 25); test('should return User when repository returns user', () async { // Arrange when(mockRepository.getUser(tUserId)) .thenAnswer((_) async => tUser); // Act final result = await userService.getUserById(tUserId); // Assert expect(result, tUser); verify(mockRepository.getUser(tUserId)); verifyNoMoreInteractions(mockRepository); }); test('should throw ArgumentError when id is empty', () async { // Act & Assert expect( () => userService.getUserById(''), throwsA(isA()), ); verifyNever(mockRepository.getUser(any)); }); test('should propagate exception from repository', () async { // Arrange when(mockRepository.getUser(tUserId)) .thenThrow(Exception('Network error')); // Act & Assert expect( () => userService.getUserById(tUserId), throwsException, ); }); }); group('isAdult', () { test('should return true when user age is 18 or more', () async { // Arrange final tUser = User(id: '1', name: 'Adult', age: 25); when(mockRepository.getUser(any)) .thenAnswer((_) async => tUser); // Act final result = await userService.isAdult('1'); // Assert expect(result, true); }); test('should return false when user age is less than 18', () async { // Arrange final tUser = User(id: '1', name: 'Minor', age: 16); when(mockRepository.getUser(any)) .thenAnswer((_) async => tUser); // Act final result = await userService.isAdult('1'); // Assert expect(result, false); }); }); }); } ``` ## Testear BLoCs/Cubits ```dart // lib/bloc/counter_bloc.dart class CounterBloc extends Bloc { CounterBloc() : super(0) { on((event, emit) => emit(state + 1)); on((event, emit) => emit(state - 1)); on((event, emit) => emit(0)); } } // test/bloc/counter_bloc_test.dart import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('CounterBloc', () { blocTest( 'emits [1] when Increment is added', build: () => CounterBloc(), act: (bloc) => bloc.add(Increment()), expect: () => [1], ); blocTest( 'emits [-1] when Decrement is added', build: () => CounterBloc(), act: (bloc) => bloc.add(Decrement()), expect: () => [-1], ); blocTest( 'emits [1, 2, 3] when three Increments are added', build: () => CounterBloc(), act: (bloc) { bloc.add(Increment()); bloc.add(Increment()); bloc.add(Increment()); }, expect: () => [1, 2, 3], ); blocTest( 'emits [0] when Reset is added after increments', build: () => CounterBloc(), seed: () => 5, act: (bloc) => bloc.add(Reset()), expect: () => [0], ); }); } ``` ## Test-Driven Development (TDD) ### Ciclo Red-Green-Refactor ```mermaid graph TD A[Red: Escribir test que falla] -->|Falla| B[Green: Escribir código mínimo] B -->|Pasa| C[Refactor: Mejorar código] C -->|Tests siguen pasando| A style A fill:#ffcccc,stroke:#ff0000,stroke-width:2px style B fill:#ccffcc,stroke:#00ff00,stroke-width:2px style C fill:#ccccff,stroke:#0000ff,stroke-width:2px ``` 1. **Red**: Escribe un test que falle 2. **Green**: Escribe el código mínimo para que pase 3. **Refactor**: Mejora el código manteniendo los tests verdes ```dart // 1. RED - Escribir test que falla test('Calculator should multiply two numbers', () { final calculator = Calculator(); expect(calculator.multiply(3, 4), 12); }); // ❌ Error: The method 'multiply' isn't defined // 2. GREEN - Implementar código mínimo class Calculator { int multiply(int a, int b) => a * b; } // ✅ Test pasa // 3. REFACTOR - Mejorar si es necesario class Calculator { int multiply(int a, int b) { if (a == 0 || b == 0) return 0; return a * b; } } // ✅ Tests siguen pasando ``` ## Matchers Comunes ```dart void main() { test('Common matchers', () { // Igualdad expect(1 + 1, 2); expect(1 + 1, equals(2)); // Tipos expect('hello', isA()); expect(42, isA()); // Booleanos expect(true, isTrue); expect(false, isFalse); // Nulos expect(null, isNull); expect('not null', isNotNull); // Contenido expect([1, 2, 3], contains(2)); expect('hello world', contains('world')); // Listas expect([1, 2, 3], hasLength(3)); expect([1, 2, 3], containsAll([1, 3])); // Rangos expect(5, greaterThan(3)); expect(5, lessThan(10)); expect(5, closeTo(5.1, 0.2)); // Excepciones expect(() => throw Exception(), throwsException); expect(() => throw ArgumentError(), throwsA(isA())); }); } ``` ## Mejores Prácticas ### ✅ Hacer ```dart // Buenos nombres descriptivos test('should return error when email is invalid', () {}); test('should save user to database when valid data provided', () {}); // Un assert por test test('should calculate total price correctly', () { final total = cart.calculateTotal(); expect(total, 100.0); }); // Usar setUp y tearDown group('UserService tests', () { late UserService service; late MockRepository mockRepo; setUp(() { mockRepo = MockRepository(); service = UserService(mockRepo); }); tearDown(() { // Limpieza si es necesaria }); test('...', () {}); }); // Testear casos límite test('should handle empty list', () {}); test('should handle null values', () {}); test('should handle very large numbers', () {}); ``` ### ❌ Evitar ```dart // ❌ Nombres vagos test('test1', () {}); test('it works', () {}); // ❌ Múltiples asserts sin relación test('user operations', () { expect(user.name, 'John'); expect(user.age, 25); expect(user.email, '[email protected]'); expect(user.isActive, true); // Demasiado, separa en tests individuales }); // ❌ Tests que dependen de otros test('create user', () { userId = service.createUser(); }); test('update user', () { service.updateUser(userId); // ❌ Depende del test anterior }); // ❌ Tests lentos test('should process data', () async { await Future.delayed(Duration(seconds: 10)); // ❌ Muy lento }); ``` ## Coverage (Cobertura de Código) ```bash # Ejecutar tests con coverage flutter test --coverage # Ver reporte HTML genhtml coverage/lcov.info -o coverage/html open coverage/html/index.html ``` ## Estructura de Archivos de Test ``` test/ ├── unit/ │ ├── models/ │ │ └── user_test.dart │ ├── services/ │ │ └── user_service_test.dart │ └── utils/ │ └── validators_test.dart ├── widget/ │ └── ... └── integration/ └── ... ``` ## Conclusión El unit testing es fundamental para: - ✅ **Confianza en el código** - ✅ **Desarrollo más rápido a largo plazo** - ✅ **Documentación viva del comportamiento** - ✅ **Facilitar refactoring** > "El código sin tests es código legacy desde el día 1" - Michael Feathers

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.