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
/// 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:
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()
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
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
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.
/// 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 secuenciastartRun()- Dispara manualmente el estado runningendRunWithData(TResult data)- Completa ejecución con un resultadoendRunNoData()- Completa ejecución sin resultado (commands void)endRunWithError(String message)- Completa ejecución con un errorrunCount- 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:
// 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):
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 completadoAutomático via run() (Dispara-y-Olvida Rápido):
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_itpara 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_itpara 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
- Fundamentos de Command — Creando commands
- Propiedades del Command — Propiedades observables
- Manejo de Errores (Error Handling) — Gestión de errores
- Mejores Prácticas — Patrones de producción