名称: 异步redux测试视图模型
描述: 在隔离环境中测试StoreConnector视图模型。涵盖使用Vm.createFrom()创建视图模型、测试视图模型属性、测试分派动作的回调以及验证回调中的状态变化。
在AsyncRedux中测试视图模型
由VmFactory创建的视图模型可以在不构建小部件的情况下进行隔离测试。使用Vm.createFrom()直接实例化视图模型,然后验证属性和执行回调。
为测试创建视图模型
使用Vm.createFrom()与存储和工厂实例:
import 'package:flutter_test/flutter_test.dart';
import 'package:async_redux/async_redux.dart';
test('视图模型具有正确属性', () {
var store = Store<AppState>(
initialState: AppState(name: 'Mary', counter: 5),
);
var vm = Vm.createFrom(store, CounterFactory());
expect(vm.counter, 5);
expect(vm.name, 'Mary');
});
重要: Vm.createFrom()每个工厂实例只能调用一次。为每个测试创建一个新工厂。
测试视图模型属性
验证工厂正确将状态转换为视图模型属性:
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: '计数为 ${state.counter}',
onIncrement: () => dispatch(IncrementAction()),
);
}
test('工厂正确转换状态', () {
var store = Store<AppState>(
initialState: AppState(counter: 10),
);
var vm = Vm.createFrom(store, CounterFactory());
expect(vm.counter, 10);
expect(vm.description, '计数为 10');
});
测试分派动作的回调
测试回调时,调用它们,然后使用等待方法验证动作被分派和状态改变:
test('onIncrement分派IncrementAction', () async {
var store = Store<AppState>(
initialState: AppState(counter: 0),
);
var vm = Vm.createFrom(store, CounterFactory());
// 调用回调
vm.onIncrement();
// 等待动作完成
await store.waitActionType(IncrementAction);
// 验证状态改变
expect(store.state.counter, 1);
});
回调测试的等待方法
几种等待方法帮助验证回调行为:
waitActionType
等待特定动作类型完成:
test('回调分派预期动作', () 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
等待多个动作类型完成:
test('回调触发多个动作', () 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
等待任何匹配动作完成,适用于测试可能分派的动作:
test('刷新触发数据获取', () 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
等待状态满足特定条件:
test('加载完成当数据被获取', () 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
等待直到没有动作在进行中:
test('所有动作完成', () async {
var store = Store<AppState>(initialState: AppState.initialState());
var vm = Vm.createFrom(store, BatchFactory());
vm.onProcessBatch();
await store.waitAllActions([]);
expect(store.state.batchProcessed, isTrue);
});
测试带动作状态的回调
验证回调分派动作成功或失败:
test('保存回调处理错误', () async {
var store = Store<AppState>(
initialState: AppState(data: ''),
);
var vm = Vm.createFrom(store, FormFactory());
// 使用无效数据触发保存
vm.onSave('');
// dispatchAndWait返回ActionStatus,但测试回调时,
// 使用waitActionType并检查store.errors
await store.waitActionType(SaveAction);
// 检查动作是否失败
expect(store.errors, isNotEmpty);
});
测试异步回调
异步回调以相同方式工作 - 等待分派的动作:
class UserFactory extends VmFactory<AppState, UserConnector, UserViewModel> {
@override
UserViewModel fromStore() => UserViewModel(
user: state.user,
onRefresh: () => dispatch(FetchUserAction()),
);
}
test('onRefresh加载用户数据', () 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);
});
使用模拟动作进行测试
使用MockStore模拟回调触发的动作:
test('带模拟依赖的回调', () async {
var store = MockStore<AppState>(
initialState: AppState(data: null),
mocks: {
// 模拟API调用返回测试数据
FetchDataAction: (action, state) => state.copy(data: '模拟数据'),
},
);
var vm = Vm.createFrom(store, DataFactory());
vm.onFetch();
await store.waitActionType(FetchDataAction);
expect(store.state.data, '模拟数据');
});
测试onInit和onDispose生命周期
使用ConnectorTester测试生命周期回调而不构建小部件:
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分派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分派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';
// 视图模型
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]);
}
// 工厂
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('使用正确的初始属性创建视图模型', () {
var vm = Vm.createFrom(store, TodoFactory());
expect(vm.todos, isEmpty);
expect(vm.isLoading, isFalse);
});
test('onAddTodo分派AddTodoAction', () async {
var vm = Vm.createFrom(store, TodoFactory());
vm.onAddTodo('买牛奶');
await store.waitActionType(AddTodoAction);
expect(store.state.todos, contains('买牛奶'));
});
test('onRemoveTodo分派RemoveTodoAction', () async {
store = Store<AppState>(
initialState: AppState(todos: ['任务1', '任务2'], isLoading: false),
);
var vm = Vm.createFrom(store, TodoFactory());
vm.onRemoveTodo(0);
await store.waitActionType(RemoveTodoAction);
expect(store.state.todos, ['任务2']);
});
test('onRefresh获取待办事项', () async {
var vm = Vm.createFrom(store, TodoFactory());
vm.onRefresh();
await store.waitCondition((state) => !state.isLoading);
expect(store.state.todos, isNotEmpty);
});
});
}
测试组织
遵循推荐的文件命名约定:
- 小部件:
todo_screen.dart - 连接器:
todo_screen_connector.dart - 状态测试:
todo_screen_STATE_test.dart - 连接器测试:
todo_screen_CONNECTOR_test.dart - 表示测试:
todo_screen_PRESENTATION_test.dart
连接器测试专注于视图模型逻辑 - 验证属性正确从状态派生,回调分派适当动作。
参考
文档中的URL:
- https://asyncredux.com/flutter/testing/testing-the-view-model
- https://asyncredux.com/flutter/testing/testing-oninit-ondispose
- https://asyncredux.com/flutter/testing/dispatch-wait-and-expect
- https://asyncredux.com/flutter/testing/test-files
- https://asyncredux.com/flutter/testing/mocking
- https://asyncredux.com/flutter/testing/store-tester
- https://asyncredux.com/flutter/connector/store-connector
- https://asyncredux.com/flutter/connector/advanced-view-model
- https://asyncredux.com/flutter/connector/connector-pattern
- https://asyncredux.com/flutter/miscellaneous/advanced-waiting