OpenParable
Skip to content
← Back to skills

asyncredux-testing-view-models

Testing & Quality

Test StoreConnector view-models in isolation. Covers creating view-models with `Vm.createFrom()`, testing view-model properties, testing callbacks that dispatch actions, and verifying state changes from callbacks.

View on GitHub ↗
QUICK START

How to use this skill

Bring this guide into your coding agent with a prompt tailored to the tool you use.

  1. Open your project in Codex.
  2. Copy and paste the prompt below.
  3. Review files and risks before approving any action.
Prompt to paste
Inspect the Agent Skill at https://github.com/marcglasberg/async_redux/blob/HEAD/.claude/skills/asyncredux-testing-view-models/SKILL.md and all supporting files. Report provenance, permissions, scripts, dependencies, and security risks. Propose an exact installation into .agents/skills/asyncredux-testing-view-models, but wait for my approval before writing files or running scripts.

Copying this prompt does not install or run the skill. Review third-party files before use.

Testing View-Models in AsyncRedux

View-models created by VmFactory can be tested in isolation without building widgets. Use Vm.createFrom() to instantiate the view-model directly, then verify properties and execute callbacks.

Creating a View-Model for Testing

Use Vm.createFrom() with a store and factory instance:

import 'package:flutter_test/flutter_test.dart';
import 'package:async_redux/async_redux.dart';

test('view-model has correct properties', () {
  var store = Store<AppState>(
    initialState: AppState(name: 'Mary', counter: 5),
  );

  var vm = Vm.createFrom(store, CounterFactory());

  expect(vm.counter, 5);
  expect(vm.name, 'Mary');
});

Important: Vm.createFrom() can only be called once per factory instance. Create a new factory for each test.

Testing View-Model Properties

Verify that the factory correctly transforms state into view-model properties:

class CounterViewModel extends Vm {
  final int counter;
  final String description;
  final VoidCallback onIncrement;

  CounterViewModel({
    required this.counter,
    required this.description,
    required this.onIncrement,
  }) : super(equals: [counter, description]);
}

class CounterFactory extends VmFactory<AppState, CounterConnector, CounterViewModel> {
  @override
  CounterViewModel fromStore() => CounterViewModel(
    counter: state.counter,
    description: 'Count is ${state.counter}',
    onIncrement: () => dispatch(IncrementAction()),
  );
}

test('factory transforms state correctly', () {
  var store = Store<AppState>(
    initialState: AppState(counter: 10),
  );

  var vm = Vm.createFrom(store, CounterFactory());

  expect(vm.counter, 10);
  expect(vm.description, 'Count is 10');
});

Testing Callbacks That Dispatch Actions

When testing callbacks, invoke them and then use wait methods to verify actions were dispatched and state changed:

test('onIncrement dispatches IncrementAction', () async {
  var store = Store<AppState>(
    initialState: AppState(counter: 0),
  );

  var vm = Vm.createFrom(store, CounterFactory());

  // Invoke the callback
  vm.onIncrement();

  // Wait for the action to complete
  await store.waitActionType(IncrementAction);

  // Verify state changed
  expect(store.state.counter, 1);
});

Wait Methods for Callback Testing

Several wait methods help verify callback behavior:

waitActionType

Wait for a specific action type to finish:

test('callback dispatches expected action', () async {
  var store = Store<AppState>(initialState: AppState(name: ''));
  var vm = Vm.createFrom(store, UserFactory());

  vm.onSave('John');
  await store.waitActionType(SaveNameAction);

  expect(store.state.name, 'John');
});

waitAllActionTypes

Wait for multiple action types to complete:

test('callback triggers multiple actions', () async {
  var store = Store<AppState>(initialState: AppState.initialState());
  var vm = Vm.createFrom(store, CheckoutFactory());

  vm.onCheckout();
  await store.waitAllActionTypes([ValidateCartAction, ProcessPaymentAction]);

  expect(store.state.orderCompleted, isTrue);
});

waitAnyActionTypeFinishes

Wait for any matching action to finish, useful when testing actions that may or may not be dispatched:

test('refresh triggers data fetch', () async {
  var store = Store<AppState>(initialState: AppState.initialState());
  var vm = Vm.createFrom(store, DataFactory());

  vm.onRefresh();
  var action = await store.waitAnyActionTypeFinishes([FetchDataAction]);

  expect(action, isA<FetchDataAction>());
  expect(store.state.data, isNotEmpty);
});

waitCondition

Wait for state to meet a specific condition:

test('loading completes when data is fetched', () async {
  var store = Store<AppState>(initialState: AppState(isLoading: false, data: null));
  var vm = Vm.createFrom(store, DataFactory());

  vm.onLoad();
  await store.waitCondition((state) => state.data != null);

  expect(store.state.isLoading, isFalse);
  expect(store.state.data, isNotNull);
});

waitAllActions

Wait until no actions are in progress:

test('all actions complete', () async {
  var store = Store<AppState>(initialState: AppState.initialState());
  var vm = Vm.createFrom(store, BatchFactory());

  vm.onProcessBatch();
  await store.waitAllActions([]);

  expect(store.state.batchProcessed, isTrue);
});

Testing Callbacks with Action Status

Verify that callbacks dispatch actions that succeed or fail appropriately:

test('save callback handles errors', () async {
  var store = Store<AppState>(
    initialState: AppState(data: ''),
  );

  var vm = Vm.createFrom(store, FormFactory());

  // Trigger save with invalid data
  vm.onSave('');

  // dispatchAndWait returns ActionStatus, but when testing callbacks,
  // use waitActionType and check store.errors
  await store.waitActionType(SaveAction);

  // Check if action failed
  expect(store.errors, isNotEmpty);
});

Testing Async Callbacks

Async callbacks work the same way - wait for the dispatched actions:

class UserFactory extends VmFactory<AppState, UserConnector, UserViewModel> {
  @override
  UserViewModel fromStore() => UserViewModel(
    user: state.user,
    onRefresh: () => dispatch(FetchUserAction()),
  );
}

test('onRefresh loads user data', () async {
  var store = Store<AppState>(
    initialState: AppState(user: null),
  );

  var vm = Vm.createFrom(store, UserFactory());

  vm.onRefresh();
  await store.waitActionType(FetchUserAction);

  expect(store.state.user, isNotNull);
});

Testing with Mocked Actions

Use MockStore to mock actions triggered by callbacks:

test('callback with mocked dependency', () async {
  var store = MockStore<AppState>(
    initialState: AppState(data: null),
    mocks: {
      // Mock the API call to return test data
      FetchDataAction: (action, state) => state.copy(data: 'mocked data'),
    },
  );

  var vm = Vm.createFrom(store, DataFactory());

  vm.onFetch();
  await store.waitActionType(FetchDataAction);

  expect(store.state.data, 'mocked data');
});

Testing onInit and onDispose Lifecycle

Use ConnectorTester to test lifecycle callbacks without building widgets:

class MyScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) => StoreConnector<AppState, MyViewModel>(
    vm: () => MyFactory(),
    onInit: (store) => store.dispatch(StartPollingAction()),
    onDispose: (store) => store.dispatch(StopPollingAction()),
    builder: (context, vm) => MyWidget(vm: vm),
  );
}

test('onInit dispatches StartPollingAction', () async {
  var store = Store<AppState>(initialState: AppState.initialState());
  var connectorTester = store.getConnectorTester(MyScreen());

  connectorTester.runOnInit();
  var action = await store.waitAnyActionTypeFinishes([StartPollingAction]);

  expect(action, isA<StartPollingAction>());
});

test('onDispose dispatches StopPollingAction', () async {
  var store = Store<AppState>(initialState: AppState.initialState());
  var connectorTester = store.getConnectorTester(MyScreen());

  connectorTester.runOnDispose();
  var action = await store.waitAnyActionTypeFinishes([StopPollingAction]);

  expect(action, isA<StopPollingAction>());
});

Complete Test Example

import 'package:flutter_test/flutter_test.dart';
import 'package:async_redux/async_redux.dart';

// View-Model
class TodoViewModel extends Vm {
  final List<String> todos;
  final bool isLoading;
  final void Function(String) onAddTodo;
  final void Function(int) onRemoveTodo;
  final VoidCallback onRefresh;

  TodoViewModel({
    required this.todos,
    required this.isLoading,
    required this.onAddTodo,
    required this.onRemoveTodo,
    required this.onRefresh,
  }) : super(equals: [todos, isLoading]);
}

// Factory
class TodoFactory extends VmFactory<AppState, TodoConnector, TodoViewModel> {
  @override
  TodoViewModel fromStore() => TodoViewModel(
    todos: state.todos,
    isLoading: state.isLoading,
    onAddTodo: (text) => dispatch(AddTodoAction(text)),
    onRemoveTodo: (index) => dispatch(RemoveTodoAction(index)),
    onRefresh: () => dispatch(FetchTodosAction()),
  );
}

void main() {
  group('TodoFactory', () {
    late Store<AppState> store;

    setUp(() {
      store = Store<AppState>(
        initialState: AppState(todos: [], isLoading: false),
      );
    });

    test('creates view-model with correct initial properties', () {
      var vm = Vm.createFrom(store, TodoFactory());

      expect(vm.todos, isEmpty);
      expect(vm.isLoading, isFalse);
    });

    test('onAddTodo dispatches AddTodoAction', () async {
      var vm = Vm.createFrom(store, TodoFactory());

      vm.onAddTodo('Buy milk');
      await store.waitActionType(AddTodoAction);

      expect(store.state.todos, contains('Buy milk'));
    });

    test('onRemoveTodo dispatches RemoveTodoAction', () async {
      store = Store<AppState>(
        initialState: AppState(todos: ['Task 1', 'Task 2'], isLoading: false),
      );
      var vm = Vm.createFrom(store, TodoFactory());

      vm.onRemoveTodo(0);
      await store.waitActionType(RemoveTodoAction);

      expect(store.state.todos, ['Task 2']);
    });

    test('onRefresh fetches todos', () async {
      var vm = Vm.createFrom(store, TodoFactory());

      vm.onRefresh();
      await store.waitCondition((state) => !state.isLoading);

      expect(store.state.todos, isNotEmpty);
    });
  });
}

Test Organization

Follow the recommended naming convention for test files:

  • Widget: todo_screen.dart
  • Connector: todo_screen_connector.dart
  • State tests: todo_screen_STATE_test.dart
  • Connector tests: todo_screen_CONNECTOR_test.dart
  • Presentation tests: todo_screen_PRESENTATION_test.dart

Connector tests focus on view-model logic - verifying properties are correctly derived from state and callbacks dispatch appropriate actions.

References

URLs from the documentation: