Skip to content

Testing de Commands

Aprende cómo escribir tests efectivos para commands, verificar transiciones de estado, y probar manejo de errores. command_it está diseñado para ser altamente testeable.

Por Qué los Commands Son Fáciles de Testear

Los Commands proporcionan interfaces claras para testing:

  • Estado observable: Todos los cambios de estado via ValueListenable
  • Comportamiento predecible: Run → ejecutar → notificar
  • Contención de errores: Los errores no crashean tests
  • Sin dependencias de UI: Testea lógica de negocio independientemente

Patrón Básico de Testing

dart
/// Helper class to collect ValueListenable emissions during tests
class Collector<T> {
  List<T>? values;

  void call(T value) {
    values ??= <T>[];
    values!.add(value);
  }

  void reset() {
    values?.clear();
    values = null;
  }
}

void main() {
  late MockApi mockApi;
  late Collector<String> resultCollector;
  late Collector<bool> isRunningCollector;
  late Collector<CommandError?> errorCollector;

  setUp(() {
    mockApi = MockApi();
    resultCollector = Collector<String>();
    isRunningCollector = Collector<bool>();
    errorCollector = Collector<CommandError?>();
  });

  group('Command Basic Tests', () {
    test('Command executes successfully', () async {
      final command = Command.createAsyncNoParam<String>(
        () => mockApi.fetchData(),
        initialValue: '',
      );

      // Set up listeners
      command.listen((result, _) => resultCollector(result));
      command.isRunning.listen((running, _) => isRunningCollector(running));

      // Execute command
      final result = await command.runAsync();

      expect(result, 'Data 1');
      expect(mockApi.callCount, 1);
      expect(resultCollector.values, ['', 'Data 1']);
      expect(isRunningCollector.values, [false, true, false]);
    });

    test('Command handles errors correctly', () async {
      mockApi.shouldFail = true;

      final command = Command.createAsyncNoParam<String>(
        () => mockApi.fetchData(),
        initialValue: '',
      );

      // Listen to errors
      command.errors.listen((error, _) => errorCollector(error));

      // Execute and expect error
      try {
        await command.runAsync();
        fail('Should have thrown');
      } catch (e) {
        expect(e.toString(), contains('API Error'));
      }

      expect(errorCollector.values?.length, 2); // null, then error
      expect(
          errorCollector.values?.last?.error.toString(), contains('API Error'));
    });

    test('Command prevents parallel execution', () async {
      var executionCount = 0;

      final command = Command.createAsyncNoParam<int>(
        () async {
          executionCount++;
          await Future.delayed(Duration(milliseconds: 50));
          return executionCount;
        },
        initialValue: 0,
      );

      // Start multiple executions rapidly
      command.run();
      command.run();
      command.run();

      // Wait for completion
      await Future.delayed(Duration(milliseconds: 100));

      // Only one execution should have occurred
      expect(executionCount, 1);
    });
  });

  group('Command with Restrictions', () {
    test('Restriction prevents execution', () {
      final restriction = ValueNotifier<bool>(false);
      var executionCount = 0;

      final command = Command.createSyncNoParamNoResult(
        () => executionCount++,
        restriction: restriction,
      );

      // Can execute when not restricted
      expect(command.canRun.value, true);
      command.run();
      expect(executionCount, 1);

      // Cannot execute when restricted
      restriction.value = true;
      expect(command.canRun.value, false);
      command.run();
      expect(executionCount, 1); // Still 1, didn't execute
    });

    test('canRun combines restriction and running state', () {
      final restriction = ValueNotifier<bool>(false);
      var executionCount = 0;

      final command = Command.createSyncNoParamNoResult(
        () => executionCount++,
        restriction: restriction,
      );

      expect(command.canRun.value, true); // Not restricted, not running

      restriction.value = true;
      expect(command.canRun.value, false); // Restricted

      restriction.value = false;
      expect(command.canRun.value, true); // No longer restricted
    });
  });

  group('CommandResult Testing', () {
    test('CommandResult state transitions', () async {
      final resultCollector = Collector<CommandResult<void, String>>();

      final command = Command.createAsyncNoParam<String>(
        () => mockApi.fetchData(),
        initialValue: 'initial',
      );

      command.results.listen((result, _) => resultCollector(result));

      await command.runAsync();

      final results = resultCollector.values!;

      // Initial state
      expect(results[0].data, 'initial');
      expect(results[0].isRunning, false);
      expect(results[0].hasError, false);

      // Running state
      expect(results[1].isRunning, true);
      expect(results[1].data, null); // Data cleared during execution

      // Success state
      expect(results[2].isRunning, false);
      expect(results[2].data, 'Data 1');
      expect(results[2].hasError, false);
    });

    test('includeLastResultInCommandResults keeps old data', () async {
      final resultCollector = Collector<CommandResult<void, String>>();

      final command = Command.createAsyncNoParam<String>(
        () => mockApi.fetchData(),
        initialValue: 'initial',
        includeLastResultInCommandResults: true, // Keep old data
      );

      command.results.listen((result, _) => resultCollector(result));

      await command.runAsync();

      final results = resultCollector.values!;

      // Running state keeps old data
      expect(results[1].isRunning, true);
      expect(results[1].data, 'initial'); // Old data still visible

      // Success state
      expect(results[2].data, 'Data 1');
    });
  });

  group('Error Filter Testing', () {
    test('ErrorFilter routes errors correctly', () async {
      var localHandlerCalled = false;
      var globalHandlerCalled = false;

      Command.globalExceptionHandler = (error, stackTrace) {
        globalHandlerCalled = true;
      };

      final command = Command.createAsyncNoParam<String>(
        () => throw Exception('Test error'),
        initialValue: '',
        errorFilter: PredicatesErrorFilter([
          (error, stackTrace) => ErrorReaction.localHandler,
        ]),
      );

      command.errors.listen((error, _) {
        if (error != null) localHandlerCalled = true;
      });

      try {
        await command.runAsync();
      } catch (_) {}

      expect(localHandlerCalled, true);
      expect(globalHandlerCalled, false); // Only local handler
    });
  });

  group('Sync Command Testing', () {
    test('Sync command executes immediately', () {
      var result = '';

      final command = Command.createSyncNoParam<String>(
        () => 'immediate',
        initialValue: '',
      );

      command.listen((value, _) => result = value);

      command.run();

      expect(result, 'immediate');
    });

    test('Sync command does not have isRunning', () {
      final command = Command.createSyncNoParam<String>(
        () => 'test',
        initialValue: '',
      );

      // Accessing isRunning on sync command throws
      expect(
        () => command.isRunning,
        throwsA(isA<AssertionError>()),
      );
    });
  });
}

El Patrón Collector

Usa un helper Collector para acumular emisiones de ValueListenable:

dart
class Collector<T> {
  List<T>? values;

  void call(T value) {
    values ??= <T>[];
    values!.add(value);
  }

  void reset() {
    values?.clear();
    values = null;
  }
}

// Uso en tests
final resultCollector = Collector<String>();
command.listen((result, _) => resultCollector(result));

await command.runAsync();

expect(resultCollector.values, ['initial', 'loaded data']);

¿Por qué este patrón?

Los Commands están diseñados para funcionar asíncronamente sin ser awaited - están hechos para ser observados, no awaited. Este es el principio arquitectural central de command_it:

  • Los Commands emiten cambios de estado via ValueListenable (results, errors, isRunning)
  • La UI observa commands reactivamente, no via await
  • Los tests necesitan verificar la secuencia de valores emitidos
  • Collector acumula todas las emisiones para que puedas hacer assertions sobre transiciones de estado completas

Mientras runAsync() es útil cuando necesitas hacer await de un resultado (como con RefreshIndicator), el patrón Collector testea commands de la manera en que típicamente se usan: dispara-y-olvida con observación.

Testing de Commands Async

Usando runAsync()

dart
test('Command async se ejecuta exitosamente', () async {
  final command = Command.createAsyncNoParam<String>(
    () async {
      await Future.delayed(Duration(milliseconds: 100));
      return 'resultado';
    },
    initialValue: '',
  );

  // Await del resultado
  final result = await command.runAsync();

  expect(result, 'resultado');
});

Testing de Manejo de Errores

Testing Básico de Errores

dart
test('Command maneja errores', () async {
  final errorCollector = Collector<CommandError?>();

  final command = Command.createAsyncNoParam<String>(
    () async {
      throw Exception('Error de prueba');
    },
    initialValue: '',
  );

  command.errors.listen((error, _) => errorCollector(error));

  try {
    await command.runAsync();
    fail('Debería haber lanzado');
  } catch (e) {
    expect(e.toString(), contains('Error de prueba'));
  }

  // errors emite null primero, luego el error
  expect(errorCollector.values?.length, 2);
  expect(errorCollector.values?.last?.error.toString(), contains('Error de prueba'));
});

Testing de ErrorFilters

dart
test('ErrorFilter enruta errores correctamente', () async {
  var localHandlerCalled = false;
  var globalHandlerCalled = false;

  Command.globalExceptionHandler = (error, stackTrace) {
    globalHandlerCalled = true;
  };

  final command = Command.createAsyncNoParam<String>(
    () => throw Exception('Error de prueba'),
    initialValue: '',
    errorFilter: PredicatesErrorFilter([
      (error, stackTrace) => ErrorReaction.localHandler,
    ]),
  );

  command.errors.listen((error, _) {
    if (error != null) localHandlerCalled = true;
  });

  try {
    await command.runAsync();
  } catch (_) {}

  expect(localHandlerCalled, true);
  expect(globalHandlerCalled, false); // Solo local
});

MockCommand

Para testing de código que depende de commands, usa la clase incorporada MockCommand para crear entornos de test controlados. El patrón de abajo muestra cómo crear un manager real con commands reales, luego una versión mock para testing.

dart
/// Real service with actual command
class DataService {
  late final loadCommand = Command.createAsync<String, List<Item>>(
    (query) => getIt<ApiClient>().search(query),
    initialValue: [],
  );
}

/// Mock service for testing - overrides command with MockCommand
class MockDataService implements DataService {
  @override
  late final loadCommand = MockCommand<String, List<Item>>(
    initialValue: [],
  );

  // Control methods make tests readable and maintainable
  void queueSuccess(String query, List<Item> data) {
    (loadCommand as MockCommand<String, List<Item>>)
        .queueResultsForNextRunCall([
      CommandResult<String, List<Item>>(query, data, null, false),
    ]);
  }

  void simulateError(String message) {
    (loadCommand as MockCommand).endRunWithError(message);
  }
}

// Code that depends on DataService
class DataManager {
  DataManager() {
    // Listen to service command and update local state
    getIt<DataService>().loadCommand.isRunning.listen((running, _) {
      _isLoading = running;
    });

    getIt<DataService>().loadCommand.listen((data, _) {
      _currentData = data;
    });
  }

  bool _isLoading = false;
  bool get isLoading => _isLoading;

  List<Item> _currentData = [];
  List<Item> get currentData => _currentData;

  Future<void> loadData(String query) async {
    getIt<DataService>().loadCommand(query);
  }
}

void main() {
  group('MockCommand Pattern', () {
    test('Test manager with mock service - success state', () async {
      final mockService = MockDataService();
      getIt.registerSingleton<DataService>(mockService);

      final manager = DataManager();

      final testData = [Item('1', 'Test Item')];

      // Queue result for the next execution
      mockService.queueSuccess('test', testData);

      // Execute the command through the manager
      await manager.loadData('test');

      // Wait for listener to fire
      await Future.delayed(Duration.zero);

      // Verify success state
      expect(manager.isLoading, false);
      expect(manager.currentData, testData);

      // Cleanup
      await getIt.reset();
    });

    test('Test manager with mock service - error state', () async {
      final mockService = MockDataService();
      getIt.registerSingleton<DataService>(mockService);

      final manager = DataManager();

      CommandError? capturedError;
      mockService.loadCommand.errors.listen((error, _) {
        capturedError = error;
      });

      // Simulate error without using loadData
      mockService.simulateError('Network error');

      // Wait for listener to fire
      await Future.delayed(Duration.zero);

      // Verify error state
      expect(manager.isLoading, false);
      expect(capturedError?.error.toString(), contains('Network error'));

      // Cleanup
      await getIt.reset();
    });

    test('Real service works as expected', () async {
      // Register real dependencies
      getIt.registerSingleton<ApiClient>(ApiClient());
      getIt.registerSingleton<DataService>(DataService());

      final manager = DataManager();

      // Test with real service
      await manager.loadData('flutter');

      await Future.delayed(Duration(milliseconds: 150));

      expect(manager.currentData.isNotEmpty, true);
      expect(manager.currentData.first.name, contains('flutter'));

      // Cleanup
      await getIt.reset();
    });
  });
}

Métodos clave de MockCommand:

  • queueResultsForNextRunCall(List<CommandResult<TParam, TResult>>) - Encola múltiples resultados para ser retornados en secuencia
  • startRun() - Dispara manualmente el estado running
  • endRunWithData(TResult data) - Completa ejecución con un resultado
  • endRunNoData() - Completa ejecución sin resultado (commands void)
  • endRunWithError(String message) - Completa ejecución con un error
  • runCount - Trackea cuántas veces se ejecutó el command

Control Automático vs Manual de Estado

Importante: El método run() de MockCommand automáticamente alterna isRunning, pero sucede síncronamente:

dart
// Cuando llamas run():
mockCommand.run('param');
// isRunning va: false → true → false (instantáneamente)

Este toggle síncrono significa que típicamente no capturarás el estado true en tests. Para testing de transiciones de estado, usa los métodos de control manual:

Control Manual (Recomendado para Testing):

dart
final mockCommand = MockCommand<String, String>(initialValue: '');

// Tú controlas cuándo cambia el estado
mockCommand.startRun('param');              // isRunning = true
expect(mockCommand.isRunning.value, true);  // ✅ Puede verificar estado de carga

// Después, completa la operación
mockCommand.endRunWithData('resultado');    // isRunning = false
expect(mockCommand.isRunning.value, false); // ✅ Puede verificar estado completado

Automático via run() (Dispara-y-Olvida Rápido):

dart
final mockCommand = MockCommand<String, String>(initialValue: '');

// Encola resultados primero
mockCommand.queueResultsForNextRunCall([
  CommandResult('param', 'resultado', null, false),
]);

// Luego run - isRunning brevemente true, luego inmediatamente false
mockCommand.run('param');

// isRunning ya es false ahora (síncrono)
expect(mockCommand.isRunning.value, false);

Usa métodos de control manual cuando:

  • Testing de UI de estado loading/running
  • Verificando transiciones de estado en secuencia
  • Testing de manejo de estado de error
  • Simulando operaciones de larga duración

Usa run() + queueResultsForNextRunCall() cuando:

  • Solo te importa el resultado final
  • Testing de resultados simples de éxito/error
  • No necesitas verificar estados intermedios

Este patrón demuestra:

  • ✅ Servicio real con command real usando get_it para dependencias
  • ✅ Servicio mock implementa servicio real y sobrescribe command con MockCommand
  • ✅ Métodos de control hacen código de test legible y mantenible
  • ✅ Manager usa get_it para acceder al servicio (inyección de dependencias completa)
  • ✅ Tests registran servicio mock para controlar comportamiento del command
  • ✅ Sin delays async - tests corren instantáneamente

Cuándo usar MockCommand:

  • Testing de código que depende de commands sin delays async
  • Testing de manejo de estados loading, éxito, y error
  • Testing unitario de servicios que coordinan commands
  • Cuando necesitas control preciso sobre transiciones de estado de commands

Ver También

Publicado bajo la Licencia MIT.