Skip to content

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

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>()),
      );
    });
  });
}

The Collector Pattern

Use a Collector helper to accumulate ValueListenable emissions:

dart
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()

dart
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

dart
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

dart
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:

dart
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:

dart
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

dart
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

dart
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

dart
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

dart
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

dart
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

dart
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

dart
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

dart
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:

dart
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 sequence
  • startExecuting() - Manually trigger the running state
  • endExecutionWithData(TResult data) - Complete execution with a result
  • endExecutionNoData() - Complete execution without a result (void commands)
  • endExecutionWithError(Exception error) - Complete execution with an error
  • executionCount - Track how many times the command was executed

Testing loading states:

dart
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:

dart
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:

dart
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:

dart
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

dart
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

dart
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

dart
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

dart
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

dart
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

dart
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

dart
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:**
- Use `Collector` pattern for state verification - Test both success and error paths - Verify state transitions with `CommandResult` - Use `runAsync()` to await results in tests - Mock external dependencies - Test restriction behavior - Verify disposal
  • ❌️️ **Don't:**
- Access `isRunning` on sync commands - Forget to dispose commands in tearDown - Test UI and business logic together - Rely on timing without `fake_async` - Ignore error handling tests

See Also

Released under the MIT License.