.claude/skills/asyncredux-testing-view-models/SKILL.md
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.
npx skillsauth add marcglasberg/async_redux asyncredux-testing-view-modelsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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.
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.
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');
});
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);
});
Several wait methods help verify callback behavior:
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');
});
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);
});
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);
});
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);
});
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);
});
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);
});
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);
});
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');
});
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>());
});
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);
});
});
}
Follow the recommended naming convention for test files:
todo_screen.darttodo_screen_connector.darttodo_screen_STATE_test.darttodo_screen_CONNECTOR_test.darttodo_screen_PRESENTATION_test.dartConnector tests focus on view-model logic - verifying properties are correctly derived from state and callbacks dispatch appropriate actions.
URLs from the documentation:
data-ai
Show loading states and handle action failures in widgets. Covers `isWaiting(ActionType)` for spinners, `isFailed(ActionType)` for error states, `exceptionFor(ActionType)` for error messages, and `clearExceptionFor()` to reset failure states.
data-ai
Use `waitCondition()` inside actions to pause execution until state meets criteria. Covers waiting for price thresholds, coordinating between actions, and implementing conditional workflows.
testing
Handle user-facing errors with UserException. Covers throwing UserException from actions, setting up UserExceptionDialog, customizing error dialogs with `onShowUserExceptionDialog`, and using UserExceptionAction for non-interrupting error display.
tools
Implement undo/redo functionality using state observers. Covers recording state history with stateObserver, creating a RecoverStateAction, implementing undo for the full state or partial state, and managing history limits.