Skip to content

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

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?

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

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');
});

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

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.

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

Key MockCommand methods:

  • queueResultsForNextRunCall(List<CommandResult<TParam, TResult>>) - Queue multiple results to be returned in sequence
  • startRun() - Manually trigger the running state
  • endRunWithData(TResult data) - Complete execution with a result
  • endRunNoData() - Complete execution without a result (void commands)
  • endRunWithError(String message) - Complete execution with an error
  • runCount - 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:

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

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

Automatic via run() (Quick Fire-and-Forget):

dart
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_it for dependencies
  • ✅ Mock service implements real service and overrides command with MockCommand
  • ✅ Control methods make test code readable and maintainable
  • ✅ Manager uses get_it to 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

Released under the MIT License.