Unit test de repositorio con Mocktail en Flutter: ejercicio resuelto

Unit test de repositorio con Mocktail en Flutter: ejercicio resuelto

Testear solo la UI no es suficiente. La lógica de negocio en repositorios y casos de uso necesita sus propios tests unitarios, aislados de la red y de Flutter. mocktail permite crear mocks sin generación de código, lo que simplifica el setup considerablemente.

Enunciado

Diseña un contrato PostRepository, un caso de uso GetPostsUseCase que filtra posts vacíos, y escribe tests unitarios que verifiquen el comportamiento correcto ante éxito y error, mockeando el repositorio.

Dependencias

1
2
3
4
5
6
7
dependencies:
  flutter: { sdk: flutter }

dev_dependencies:
  flutter_test:
    sdk: flutter
  mocktail: ^1.0.4

Solución: clases de dominio

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// lib/domain/models/post.dart
class Post {
  final int id;
  final String title;
  const Post({required this.id, required this.title});

  @override
  bool operator ==(Object other) =>
      other is Post && other.id == id && other.title == title;

  @override
  int get hashCode => Object.hash(id, title);
}

// lib/domain/repositories/post_repository.dart
abstract interface class PostRepository {
  Future<List<Post>> getPosts();
}

// lib/domain/use_cases/get_posts_use_case.dart
class GetPostsUseCase {
  final PostRepository _repository;

  const GetPostsUseCase(this._repository);

  /// Devuelve solo posts con título no vacío, ordenados por id.
  Future<List<Post>> call() async {
    final posts = await _repository.getPosts();
    return posts
        .where((p) => p.title.trim().isNotEmpty)
        .toList()
      ..sort((a, b) => a.id.compareTo(b.id));
  }
}

Solución: tests con Mocktail

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// test/domain/get_posts_use_case_test.dart
import 'package:mocktail/mocktail.dart';
import 'package:flutter_test/flutter_test.dart';

// Imports de tus clases de dominio
// import 'package:mi_app/domain/...';

// ── Mock ─────────────────────────────────────────────────────────────────────
class MockPostRepository extends Mock implements PostRepository {}

void main() {
  late MockPostRepository repository;
  late GetPostsUseCase useCase;

  setUp(() {
    repository = MockPostRepository();
    useCase = GetPostsUseCase(repository);
  });

  group('GetPostsUseCase', () {
    test('devuelve lista filtrada y ordenada cuando el repositorio tiene éxito', () async {
      when(() => repository.getPosts()).thenAnswer(
        (_) async => [
          const Post(id: 3, title: 'Tercero'),
          const Post(id: 1, title: 'Primero'),
          const Post(id: 2, title: ''),        // debe filtrarse
          const Post(id: 4, title: '   '),     // espacios → debe filtrarse
        ],
      );

      final result = await useCase();

      expect(result, [
        const Post(id: 1, title: 'Primero'),
        const Post(id: 3, title: 'Tercero'),
      ]);
      verify(() => repository.getPosts()).called(1);
    });

    test('propaga la excepción cuando el repositorio falla', () async {
      when(() => repository.getPosts())
          .thenThrow(Exception('Error de red'));

      expect(() => useCase(), throwsA(isA<Exception>()));
    });

    test('devuelve lista vacía si todos los posts tienen título vacío', () async {
      when(() => repository.getPosts()).thenAnswer(
        (_) async => [const Post(id: 1, title: '')],
      );

      final result = await useCase();

      expect(result, isEmpty);
    });
  });
}

Cómo ejecutar los tests

1
2
3
4
5
6
7
8
9
# Todos los tests
flutter test

# Solo este archivo
flutter test test/domain/get_posts_use_case_test.dart

# Con coverage
flutter test --coverage
genhtml coverage/lcov.info -o coverage/html

Resultado esperado

1
00:01 +3: All tests passed!

Tres casos pasan: lista filtrada y ordenada, excepción propagada, lista vacía.

Errores frecuentes

  • Mock no configurado: olvidar when(...) antes de llamar al método mockeado lanza MissingStubError. Configura el mock en setUp o al inicio del test.
  • Igualdad no implementada en el modelo: si Post no sobreescribe ==, expect(result, [Post(...)]) siempre falla aunque los valores sean iguales.
  • Verificar interacciones sin haber establecido el mock: verify funciona sobre mocks de mocktail, no sobre instancias reales.

Por qué vale la pena escribir estos tests

  • Se ejecutan en milisegundos, sin emulador ni red.
  • Documentan el comportamiento esperado de la lógica de negocio.
  • Detectan regresiones cuando cambias el caso de uso o el repositorio.
  • Sirven de especificación ejecutable para el equipo.

Aplicación práctica

Cualquier caso de uso en una app con arquitectura limpia se beneficia de tests de este tipo: filtrado de listas, transformación de datos, reglas de negocio (descuentos, validaciones, permisos).

Siguiente ejercicio recomendado

Práctica guiada y siguiente paso

FAQ

¿Por qué Mocktail en lugar de Mockito?

Mocktail no requiere generación de código con build_runner. Escribes el mock con una línea y los tests corren sin pasos previos.

¿Cuándo usar thenAnswer vs thenReturn?

Usa thenReturn para valores sincrónicos. Usa thenAnswer cuando el stub devuelve un Future o Stream.

¿Dónde pongo los tests de repositorio en el proyecto?

En test/domain/ si sigues arquitectura limpia. La convención es espejar la estructura de lib/ dentro de test/.