Testing Commands
AI-Generated Content Under Review
This documentation was generated with AI assistance and is currently under review. While we strive for accuracy, there may be errors or inconsistencies. Please report any issues you find.
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?
- Captures all emitted values
- Verifies state transitions
- Easy to reset between tests
- Works with any
ValueListenable
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');
});Verifying isRunning State
test('isRunning state transitions', () async {
final collector = Collector<bool>();
final command = Command.createAsyncNoParam<String>(
() async {
await Future.delayed(Duration(milliseconds: 50));
return 'done';
},
initialValue: '',
);
command.isRunning.listen((running, _) => collector(running));
await command.runAsync();
expect(collector.values, [false, true, false]);
// false (initial) → true (started) → false (completed)
});Testing Parallel Execution Prevention
test('Prevents parallel execution', () async {
var executionCount = 0;
final command = Command.createAsyncNoParam<int>(
() async {
executionCount++;
await Future.delayed(Duration(milliseconds: 50));
return executionCount;
},
initialValue: 0,
);
// Try to run multiple times rapidly
command.run();
command.run();
command.run();
await Future.delayed(Duration(milliseconds: 100));
// Only one execution occurred
expect(executionCount, 1);
});Testing Sync Commands
Sync commands execute immediately, simplifying tests:
test('Sync command executes immediately', () {
var result = '';
final command = Command.createSyncNoParam<String>(
() => 'immediate',
initialValue: '',
);
command.listen((value, _) => result = value);
command.run();
// No await needed
expect(result, 'immediate');
});Important: Sync commands don't have isRunning:
test('Sync command does not have isRunning', () {
final command = Command.createSyncNoParam<String>(
() => 'test',
initialValue: '',
);
// This throws AssertionError
expect(
() => command.isRunning,
throwsA(isA<AssertionError>()),
);
});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
});Testing Restrictions
Basic Restriction Test
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
});Testing canRun
test('canRun combines restriction and running', () {
final restriction = ValueNotifier<bool>(false);
final command = Command.createAsyncNoParam<void>(
() async {
await Future.delayed(Duration(milliseconds: 50));
},
restriction: restriction,
);
expect(command.canRun.value, true); // Not restricted, not running
restriction.value = true;
expect(command.canRun.value, false); // Restricted
restriction.value = false;
command.run(); // Start execution
// Note: canRun will be false during execution
});Testing CommandResult
State Transitions
test('CommandResult state transitions', () async {
final collector = Collector<CommandResult<void, String>>();
final command = Command.createAsyncNoParam<String>(
() async {
await Future.delayed(Duration(milliseconds: 50));
return 'result';
},
initialValue: 'initial',
);
command.results.listen((result, _) => collector(result));
await command.runAsync();
final results = collector.values!;
// Initial state
expect(results[0].data, 'initial');
expect(results[0].isRunning, false);
// Running state
expect(results[1].isRunning, true);
expect(results[1].data, null); // Cleared during execution
// Success state
expect(results[2].isRunning, false);
expect(results[2].data, 'result');
expect(results[2].hasError, false);
});Testing includeLastResultInCommandResults
test('includeLastResultInCommandResults keeps old data', () async {
final collector = Collector<CommandResult<void, String>>();
final command = Command.createAsyncNoParam<String>(
() async {
await Future.delayed(Duration(milliseconds: 50));
return 'new data';
},
initialValue: 'old data',
includeLastResultInCommandResults: true,
);
command.results.listen((result, _) => collector(result));
await command.runAsync();
final results = collector.values!;
// Running state KEEPS old data
expect(results[1].isRunning, true);
expect(results[1].data, 'old data'); // Still visible
// Success state
expect(results[2].data, 'new data');
});Mocking Dependencies
Using Mock Classes
class MockApi {
bool shouldFail = false;
int callCount = 0;
Future<String> fetchData() async {
callCount++;
if (shouldFail) {
throw Exception('API Error');
}
return 'Data $callCount';
}
}
test('Command with mocked dependency', () async {
final mockApi = MockApi();
final command = Command.createAsyncNoParam<String>(
() => mockApi.fetchData(),
initialValue: '',
);
final result = await command.runAsync();
expect(result, 'Data 1');
expect(mockApi.callCount, 1);
});Testing Error Scenarios with Mocks
test('Command handles API errors', () async {
final mockApi = MockApi();
mockApi.shouldFail = true;
final command = Command.createAsyncNoParam<String>(
() => mockApi.fetchData(),
initialValue: '',
);
expect(
() => command.runAsync(),
throwsA(isA<Exception>()),
);
});Using MockCommand
For testing code that depends on commands, use the built-in MockCommand class instead of creating real commands:
import 'package:command_it/command_it.dart';
test('Service uses command correctly', () async {
// Create a mock command
final mockLoadCommand = MockCommand<void, List<String>>(
initialValue: [],
);
// Queue results for the next execution
mockLoadCommand.queueResultsForNextExecuteCall([
'Item 1',
'Item 2',
'Item 3',
]);
// Inject into service
final service = DataService(loadCommand: mockLoadCommand);
// Trigger the command
service.loadData();
// Verify the command was called
expect(mockLoadCommand.executionCount, 1);
// Verify the result
expect(mockLoadCommand.value, ['Item 1', 'Item 2', 'Item 3']);
});Key MockCommand methods:
queueResultsForNextExecuteCall(List<TResult>)- Queue multiple results to be returned in sequencestartExecuting()- Manually trigger the running stateendExecutionWithData(TResult data)- Complete execution with a resultendExecutionNoData()- Complete execution without a result (void commands)endExecutionWithError(Exception error)- Complete execution with an errorexecutionCount- Track how many times the command was executed
Testing loading states:
test('UI shows loading indicator', () async {
final mockCommand = MockCommand<void, String>(
initialValue: '',
);
final loadingStates = <bool>[];
mockCommand.isRunning.listen((running, _) => loadingStates.add(running));
// Start execution manually
mockCommand.startExecuting();
expect(mockCommand.isRunning.value, true);
// Complete execution
mockCommand.endExecutionWithData('loaded data');
expect(mockCommand.isRunning.value, false);
expect(loadingStates, [false, true, false]);
});Testing error scenarios:
test('UI shows error message', () {
final mockCommand = MockCommand<void, String>(
initialValue: '',
);
CommandError? capturedError;
mockCommand.errors.listen((error, _) => capturedError = error);
// Simulate error
mockCommand.startExecuting();
mockCommand.endExecutionWithError(Exception('Network error'));
expect(capturedError?.error.toString(), contains('Network error'));
});Benefits of MockCommand:
- ✅ No async delays - tests run faster
- ✅ Full control over execution state
- ✅ Verify execution count
- ✅ Queue multiple results for sequential calls
- ✅ Test loading, success, and error states independently
- ✅ No need for real business logic in tests
When to use MockCommand:
- Testing widgets that observe commands
- Testing services that coordinate multiple commands
- Unit testing command-dependent code
- When you need precise control over command state transitions
Testing with fake_async
For precise timing control, use fake_async:
import 'package:fake_async/fake_async.dart';
test('Test with controlled time', () {
fakeAsync((async) {
var result = '';
final command = Command.createAsyncNoParam<String>(
() async {
await Future.delayed(Duration(seconds: 5));
return 'delayed result';
},
initialValue: '',
);
command.listen((value, _) => result = value);
command.run();
// Immediately after run, still initial
expect(result, '');
// Advance time
async.elapse(Duration(seconds: 5));
// Now the result is set
expect(result, 'delayed result');
});
});Testing Disposal
Verify commands clean up properly:
test('Command disposes correctly', () async {
var disposed = false;
final command = Command.createAsyncNoParam<String>(
() async => 'result',
initialValue: '',
);
// Add listener
command.listen((_, __) {});
// Dispose
await command.dispose();
// Verify disposed (accessing properties should throw)
expect(() => command.value, throwsA(anything));
});Integration Testing
Testing Commands in Managers
class DataManager {
final api = ApiClient();
late final loadCommand = Command.createAsyncNoParam<List<String>>(
() => api.fetchData(),
initialValue: [],
);
void dispose() {
loadCommand.dispose();
}
}
test('DataManager integration', () async {
final manager = DataManager();
final result = await manager.loadCommand.runAsync();
expect(result, isNotEmpty);
manager.dispose();
});Testing Command Chains
test('Commands chain via restrictions', () async {
final loadCommand = Command.createAsyncNoParam<void>(
() async {
await Future.delayed(Duration(milliseconds: 50));
},
);
final saveCommand = Command.createAsyncNoParam<void>(
() async {},
restriction: loadCommand.isRunningSync,
);
loadCommand.run();
// Save is restricted while load is running
expect(saveCommand.canRun.value, false);
await Future.delayed(Duration(milliseconds: 100));
// After load completes, save can run
expect(saveCommand.canRun.value, true);
});Common Testing Patterns
Pattern 1: Setup/Teardown
group('Command Tests', () {
late Command<void, String> command;
late Collector<String> collector;
setUp(() {
collector = Collector<String>();
command = Command.createAsyncNoParam<String>(
() async => 'result',
initialValue: '',
);
command.listen((value, _) => collector(value));
});
tearDown(() async {
await command.dispose();
collector.reset();
});
test('test 1', () async {
// Test using command and collector
});
test('test 2', () async {
// Test using command and collector
});
});Pattern 2: Verify All States
test('Verify complete state flow', () async {
final states = <String>[];
final command = Command.createAsyncNoParam<String>(
() async {
await Future.delayed(Duration(milliseconds: 50));
return 'done';
},
initialValue: 'initial',
);
command.results.listen((result, _) {
if (result.isRunning) {
states.add('running');
} else if (result.hasError) {
states.add('error');
} else if (result.hasData) {
states.add('success');
}
});
await command.runAsync();
expect(states, ['success', 'running', 'success']);
// success (initial), running, success (completed)
});Pattern 3: Error Recovery
test('Command recovers after error', () async {
var shouldFail = true;
final command = Command.createAsyncNoParam<String>(
() async {
if (shouldFail) {
throw Exception('Error');
}
return 'success';
},
initialValue: '',
);
// First call fails
expect(() => command.runAsync(), throwsA(anything));
await Future.delayed(Duration(milliseconds: 50));
// Second call succeeds
shouldFail = false;
final result = await command.runAsync();
expect(result, 'success');
});Debugging Tests
Enable Print Statements
void setupCollectors(Command command, {bool enablePrint = true}) {
command.canRun.listen((canRun, _) {
if (enablePrint) print('canRun: $canRun');
});
command.results.listen((result, _) {
if (enablePrint) {
print('Result: data=${result.data}, error=${result.error}, '
'isRunning=${result.isRunning}');
}
});
}Use testWidgets for UI Integration
testWidgets('CommandBuilder widget test', (tester) async {
final command = Command.createAsyncNoParam<String>(
() async => 'result',
initialValue: '',
);
await tester.pumpWidget(
MaterialApp(
home: CommandBuilder<void, String>(
command: command,
whileRunning: (context, _, __) => CircularProgressIndicator(),
onData: (context, data, _) => Text(data),
),
),
);
await tester.pumpAndSettle();
expect(find.text('result'), findsOneWidget);
});Best Practices
- ✅ **Do:**
- ❌️️ **Don't:**
See Also
- Command Basics — Creating commands
- Command Properties — Observable properties
- Error Handling — Error management
- Best Practices — Production patterns