Testing Commands
Learn how to write effective tests for commands, verify state transitions, and test error handling. command_it is designed to be highly testable.
Why Commands Are Easy to Test
Commands provide clear interfaces for testing:
- Observable state: All state changes via
ValueListenable - Predictable behavior: Run → execute → notify
- Error containment: Errors don't crash tests
- No UI dependencies: Test business logic independently
Basic Testing Pattern
/// 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>()),
);
});
});
}The Collector Pattern
Use a Collector helper to accumulate ValueListenable emissions:
class Collector<T> {
List<T>? values;
void call(T value) {
values ??= <T>[];
values!.add(value);
}
void reset() {
values?.clear();
values = null;
}
}
// Usage in tests
final resultCollector = Collector<String>();
command.listen((result, _) => resultCollector(result));
await command.runAsync();
expect(resultCollector.values, ['initial', 'loaded data']);Why this pattern?
Commands are designed to work asynchronously without being awaited - they're meant to be observed, not awaited. This is the core architectural principle of command_it:
- Commands emit state changes via
ValueListenable(results, errors, isRunning) - UI observes commands reactively, not via
await - Tests need to verify the sequence of emitted values
- Collector accumulates all emissions so you can assert on complete state transitions
While runAsync() is useful when you need to await a result (like with RefreshIndicator), the Collector pattern tests commands the way they're typically used: fire-and-forget with observation.
Testing Async Commands
Using runAsync()
test('Async command executes successfully', () async {
final command = Command.createAsyncNoParam<String>(
() async {
await Future.delayed(Duration(milliseconds: 100));
return 'result';
},
initialValue: '',
);
// Await the result
final result = await command.runAsync();
expect(result, 'result');
});Testing Error Handling
Basic Error Testing
test('Command handles errors', () async {
final errorCollector = Collector<CommandError?>();
final command = Command.createAsyncNoParam<String>(
() async {
throw Exception('Test error');
},
initialValue: '',
);
command.errors.listen((error, _) => errorCollector(error));
try {
await command.runAsync();
fail('Should have thrown');
} catch (e) {
expect(e.toString(), contains('Test error'));
}
// errors emits null first, then the error
expect(errorCollector.values?.length, 2);
expect(errorCollector.values?.last?.error.toString(), contains('Test error'));
});Testing ErrorFilters
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
});MockCommand
For testing code that depends on commands, use the built-in MockCommand class to create controlled test environments. The pattern below shows how to create a real manager with actual commands, then a mock version for 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();
});
});
}Key MockCommand methods:
queueResultsForNextRunCall(List<CommandResult<TParam, TResult>>)- Queue multiple results to be returned in sequencestartRun()- Manually trigger the running stateendRunWithData(TResult data)- Complete execution with a resultendRunNoData()- Complete execution without a result (void commands)endRunWithError(String message)- Complete execution with an errorrunCount- Track how many times the command was run
Automatic vs Manual State Control
Important: MockCommand's run() method automatically toggles isRunning, but it happens synchronously:
// When you call run():
mockCommand.run('param');
// isRunning goes: false → true → false (instantly)This synchronous toggle means you typically won't catch the true state in tests. For testing state transitions, use the manual control methods:
Manual Control (Recommended for Testing):
final mockCommand = MockCommand<String, String>(initialValue: '');
// You control when state changes
mockCommand.startRun('param'); // isRunning = true
expect(mockCommand.isRunning.value, true); // ✅ Can verify loading state
// Later, complete the operation
mockCommand.endRunWithData('result'); // isRunning = false
expect(mockCommand.isRunning.value, false); // ✅ Can verify completed stateAutomatic via run() (Quick Fire-and-Forget):
final mockCommand = MockCommand<String, String>(initialValue: '');
// Queue results first
mockCommand.queueResultsForNextRunCall([
CommandResult('param', 'result', null, false),
]);
// Then run - isRunning briefly true, then immediately false
mockCommand.run('param');
// isRunning is already false by now (synchronous)
expect(mockCommand.isRunning.value, false);Use manual control methods when:
- Testing loading/running state UI
- Verifying state transitions in sequence
- Testing error state handling
- Simulating long-running operations
Use run() + queueResultsForNextRunCall() when:
- You only care about the final result
- Testing simple success/error outcomes
- You don't need to verify intermediate states
This pattern demonstrates:
- ✅ Real service with actual command using
get_itfor dependencies - ✅ Mock service implements real service and overrides command with MockCommand
- ✅ Control methods make test code readable and maintainable
- ✅ Manager uses
get_itto access service (full dependency injection) - ✅ Tests register mock service to control command behavior
- ✅ No async delays - tests run instantly
When to use MockCommand:
- Testing code that depends on commands without async delays
- Testing loading, success, and error state handling
- Unit testing services that coordinate commands
- When you need precise control over command state transitions
See Also
- Command Basics — Creating commands
- Command Properties — Observable properties
- Error Handling — Error management
- Best Practices — Production patterns