Write unit tests for AsyncRedux actions using the Store directly. Covers creating test stores with initial state, using `dispatchAndWait()`, checking state after actions, verifying action errors via ActionStatus, and testing async actions.
The recommended approach for testing AsyncRedux is to use the Store directly rather than the deprecated StoreTester. This provides a clean, straightforward testing pattern.
Create a store with test-specific initial state:
import 'package:flutter_test/flutter_test.dart';
import 'package:async_redux/async_redux.dart';
void main() {
test('should increment counter', () async {
// Create store with initial state
var store = Store<AppState>(
initialState: AppState(counter: 0, name: ''),
);
// Test your actions here
});
}
For test isolation, create a fresh store in each test:
void main() {
late Store<AppState> store;
setUp(() {
store = Store<AppState>(
initialState: AppState.initialState(),
);
});
tearDown(() {
store.shutdown();
});
// Tests go here
}
Use dispatchAndWait() to dispatch an action and wait for it to complete:
test('SaveNameAction updates the name', () async {
var store = Store<AppState>(
initialState: AppState(name: ''),
);
await store.dispatchAndWait(SaveNameAction('John'));
expect(store.state.name, 'John');
});
Async actions work the same way - dispatchAndWait() returns only when the action fully completes:
class FetchUserAction extends ReduxAction<AppState> {
final String userId;
FetchUserAction(this.userId);
Future<AppState?> reduce() async {
var user = await api.fetchUser(userId);
return state.copy(user: user);
}
}
test('FetchUserAction loads user data', () async {
var store = Store<AppState>(
initialState: AppState(user: null),
);
await store.dispatchAndWait(FetchUserAction('123'));
expect(store.state.user, isNotNull);
expect(store.state.user!.id, '123');
});
Use dispatchAndWaitAll() to dispatch multiple actions and wait for all to complete:
test('can buy and sell stocks in parallel', () async {
var store = Store<AppState>(
initialState: AppState(portfolio: Portfolio.empty()),
);
await store.dispatchAndWaitAll([
BuyAction('IBM', quantity: 10),
SellAction('TSLA', quantity: 5),
]);
expect(store.state.portfolio.holdings['IBM'], 10);
expect(store.state.portfolio.holdings['TSLA'], isNull);
});
dispatchAndWait() returns an ActionStatus object that lets you verify if an action succeeded or failed:
test('SaveAction fails with invalid data', () async {
var store = Store<AppState>(
initialState: AppState.initialState(),
);
var status = await store.dispatchAndWait(SaveAction(amount: -100));
expect(status.isCompletedFailed, isTrue);
expect(status.isCompletedOk, isFalse);
});
isCompleted: Whether the action finished executingisCompletedOk: True if action finished without errors (both before() and reduce() completed successfully)isCompletedFailed: True if action threw an errororiginalError: The error thrown by before() or reduce()wrappedError: The error after wrapError() processinghasFinishedMethodBefore: Whether before() completedhasFinishedMethodReduce: Whether reduce() completedhasFinishedMethodAfter: Whether after() completedTest that actions throw appropriate UserException errors:
class TransferMoney extends ReduxAction<AppState> {
final double amount;
TransferMoney(this.amount);
AppState? reduce() {
if (amount <= 0) {
throw UserException('Amount must be positive.');
}
return state.copy(balance: state.balance - amount);
}
}
test('TransferMoney throws UserException for invalid amount', () async {
var store = Store<AppState>(
initialState: AppState(balance: 1000),
);
var status = await store.dispatchAndWait(TransferMoney(0));
expect(status.isCompletedFailed, isTrue);
var error = status.wrappedError;
expect(error, isA<UserException>());
expect((error as UserException).msg, 'Amount must be positive.');
});
When multiple actions fail, check the store's error queue:
test('multiple actions can fail', () async {
var store = Store<AppState>(
initialState: AppState.initialState(),
);
await store.dispatchAndWaitAll([
InvalidAction1(),
InvalidAction2(),
]);
// Check errors in the store's error queue
expect(store.errors.length, 2);
});
A common pattern is navigating only after an action succeeds:
test('navigate only on successful save', () async {
var store = Store<AppState>(
initialState: AppState.initialState(),
);
var status = await store.dispatchAndWait(SaveAction(data: validData));
expect(status.isCompletedOk, isTrue);
// In real code: if (status.isCompletedOk) Navigator.pop(context);
});
When an action throws, state should remain unchanged:
test('state unchanged when action fails', () async {
var store = Store<AppState>(
initialState: AppState(counter: 5),
);
var initialState = store.state;
await store.dispatchAndWait(FailingAction());
// State should not have changed
expect(store.state.counter, 5);
expect(store.state, initialState);
});
Use MockStore to mock specific actions in tests:
test('with mocked dependency action', () async {
var store = MockStore<AppState>(
initialState: AppState.initialState(),
mocks: {
// Disable the action (don't run it)
FetchFromServerAction: null,
// Or replace with custom state modification
FetchFromServerAction: (action, state) =>
state.copy(data: 'mocked data'),
},
);
await store.dispatchAndWait(ActionThatDependsOnFetch());
expect(store.state.data, 'mocked data');
});
For complex async scenarios, use these additional wait methods:
// Wait for a specific state condition
await store.waitCondition((state) => state.isLoaded);
// Wait for all given action types to complete
await store.waitAllActionTypes([LoadAction, ProcessAction]);
// Wait for any action of given types to finish
await store.waitAnyActionTypeFinishes([LoadAction]);
// Wait until no actions are in progress
await store.waitAllActions([]);
Recommended naming convention for test files:
my_feature.dartmy_feature_STATE_test.dartmy_feature_CONNECTOR_test.dartmy_feature_PRESENTATION_test.dartimport 'package:flutter_test/flutter_test.dart';
import 'package:async_redux/async_redux.dart';
void main() {
group('IncrementAction', () {
late Store<AppState> store;
setUp(() {
store = Store<AppState>(
initialState: AppState(counter: 0),
);
});
test('increments counter by 1', () async {
await store.dispatchAndWait(IncrementAction());
expect(store.state.counter, 1);
});
test('increments counter multiple times', () async {
await store.dispatchAndWait(IncrementAction());
await store.dispatchAndWait(IncrementAction());
await store.dispatchAndWait(IncrementAction());
expect(store.state.counter, 3);
});
test('handles concurrent increments', () async {
await store.dispatchAndWaitAll([
IncrementAction(),
IncrementAction(),
IncrementAction(),
]);
expect(store.state.counter, 3);
});
});
group('FetchDataAction', () {
test('succeeds with valid response', () async {
var store = Store<AppState>(
initialState: AppState(data: null),
);
var status = await store.dispatchAndWait(FetchDataAction());
expect(status.isCompletedOk, isTrue);
expect(store.state.data, isNotNull);
});
test('fails gracefully on error', () async {
var store = Store<AppState>(
initialState: AppState(data: null),
);
var status = await store.dispatchAndWait(
FetchDataAction(simulateError: true),
);
expect(status.isCompletedFailed, isTrue);
expect(status.wrappedError, isA<UserException>());
expect(store.state.data, isNull); // State unchanged
});
});
}
URLs from the documentation:
npx skills add marcglasberg/asyncredux-testing-basics下载完整 Skill 目录,包含 SKILL.md 及所有相关文件
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer