From 897d656f95b0b13f6c321ceef86cd842e8b65a7f Mon Sep 17 00:00:00 2001 From: Marcelo Glasberg <13332110+marcglasberg@users.noreply.github.com> Date: Fri, 8 Mar 2024 00:48:23 -0300 Subject: [PATCH] More test methods in the store. Test the view model. --- CHANGELOG.md | 69 ++++++++++++++++++++++++--- README.md | 44 +++++++++++------- lib/src/store.dart | 95 +++++++++++++++++++++++++++++++++++-- lib/src/store_tester.dart | 98 ++++++++++++++++++++++++++++----------- lib/src/view_model.dart | 71 +++++++++++++++++----------- pubspec.yaml | 2 +- 6 files changed, 297 insertions(+), 82 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb07362..f1a4820 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ an state.name == "Bill"); + expect(store.state.name, "Bill"); + ``` + + +* While the `StoreTester` is a powerful tool with advanced features that are beneficial for the + most complex testing scenarios, for **almost all tests** it's now recommended to use the `Store` + directly. This approach involves waiting for an action to complete its dispatch process or for + the store state to meet a certain condition. After this, you can verify the current state or + action using the new + methods `store.dispatchAndWait`, `store.waitCondition`, `store.waitActionType`, + and `store.waitAnyActionType`. For example: + + ```dart + // Wait for some action to dispatch and check the state. + await store.dispatchAndWait(MyAction()); + expect(store.state.name, 'John') + + // Wait for some action to dispatch, and check for errors in the action status. + var status = await dispatchAndWait(MyAction()); + expect(status.originalError, isA()); + + // Wait for some state condition + expect(store.state.name, 'John') + dispatch(ChangeNameAction("Bill")); + var action = await store.waitCondition((state) => state.name == "Bill"); + expect(action, isA()); + expect(store.state.name, 'Bill'); + + // Wait until some action of a given type is dispatched. + dispatch(DoALotOfStuffAction()); + var action = store.waitActionType(ChangeNameAction); + expect(action, isA()); + expect(action.status.isCompleteOk, isTrue); + expect(store.state.name, 'Bill'); + + // Wait until some action of any of the given types is dispatched. + dispatch(BuyAction('IBM')); + var action = store.waitAnyActionType([BuyAction, SellAction]); + expect(store.state.portfolio.contains('IBM'), isTrue); + ``` + # 21.7.1 * DEPRECATION WARNING: @@ -169,11 +231,6 @@ showcasing the fundamentals and best practices described in the AsyncRedux READM extension and put it in the same directory as your `app_state.dart` file containing your `AppState` class. - -* You can now use `var vm = MyFactory().fromStoreTester(storeTester)` - to test a view-model. Read the detailed explanation in the README.md file, - under the title `Testing the StoreConnector's View-model`. - # 21.1.1 * `await StoreTester.dispatchAndWait(action)` dispatches an action, and then waits until it diff --git a/README.md b/README.md index 86df513..220f0ac 100644 --- a/README.md +++ b/README.md @@ -1504,30 +1504,40 @@ expect(storeTester.lastInfo.state.name, "Mark"); ### Testing the StoreConnector's View-model -To test the **view-model** used by a `StoreConnector`, first create a `StoreTester`, -then create the `Factory`, and then call the `factory.fromStoreTester()` method, -passing it the store-tester. +To test the view-model generated by a `VmFactory`, use `createFrom` and pass it the +`store` and the `factory`. Note this method must be called in a recently +created factory, as it can only be called once per factory instance. -You will get the view-model, which you can use to: +The method will return the **view-model**, which you can use to: * Inspect the view-model properties directly, or + * Call any of the view-model callbacks. If the callbacks dispatch actions, - you can wait for them using the store-tester. + you use `await store.waitActionType(MyAction)`, + or `await store.waitAllActionTypes([MyAction, OtherAction])`, + or `await store.waitCondition((state) => ...)`, + or if necessary you can even record all dispatched actions and state changes + with `Store.record.start()` and `Store.record.stop()`. Example: -```dart -var storeTester = StoreTester(initialState: User("Mary")); -var vm = MyFactory().fromStoreTester(storeTester); +``` +var store = Store(initialState: User("Mary")); +var vm = Vm.createFrom(store, MyFactory()); + +// Checking a view-model property. expect(vm.user.name, "Mary"); -vm.onChangeNameTo("Bill"); // Suppose it dispatches action SetNameAction("Bill"). -var info = await storeTester.wait(SetNameAction); -expect(info.state.name, "Bill"); -``` +// Calling a view-model callback and waiting for the action to finish. +vm.onChangeNameTo("Bill"); // Dispatches SetNameAction("Bill"). +await store.waitActionType(SetNameAction); +expect(store.state.name, "Bill"); -Note: _The `fromStoreTester()` method must be called in a recently created factory, as it can be -called only once per factory instance._ +// Calling a view-model callback and waiting for the state to change. +vm.onChangeNameTo("Bill"); // Dispatches SetNameAction("Bill"). +await store.waitCondition((state) => state.name == "Bill"); +expect(store.state.name, "Bill"); +``` #### Testing the StoreConnector's onInit and onInit @@ -1789,9 +1799,9 @@ Since each widget will have a bunch of related files, you should have some consi convention. For example, if some dumb-widget is called `MyWidget`, its file could be `my_widget.dart`. Then the corresponding connector-widget could be `MyWidgetConnector` in `my_widget_CONNECTOR.dart`. The three corresponding test files could be -named `my_widget_STATE_test.dart`, -`my_widget_CONNECTOR_test.dart` and `my_widget_PRESENTATION_test.dart`. If you don't like this -convention use your own, but just choose one early and stick to it. +named `my_widget_STATE_test.dart`, `my_widget_CONNECTOR_test.dart` +and `my_widget_PRESENTATION_test.dart`. If you don't like this convention use your own, +but just choose one early and stick to it.
diff --git a/lib/src/store.dart b/lib/src/store.dart index 587b37c..6e11ca0 100644 --- a/lib/src/store.dart +++ b/lib/src/store.dart @@ -316,18 +316,103 @@ class Store { } /// Returns a future which will complete when the given [condition] is true. - /// The condition can access the state. You may also provide a - /// [timeoutInSeconds], which by default is null (never times out). - Future waitCondition( + /// The condition can access the state. You may also provide a [timeoutInSeconds], which + /// by default is 10 minutes. If you want, you can modify [StoreTester.defaultTimeout] to change + /// the default timeout. Note: To disable the timeout, modify this to a large value, + /// like 300000000 (almost 10 years). + /// + /// This method is useful in tests, and it returns the action which changed + /// the store state into the condition, in case you need it: + /// + /// ```dart + /// var action = await store.waitCondition((state) => state.name == "Bill"); + /// expect(action, isA()); + /// ``` + /// + /// This method is also eventually useful in production code, in which case you + /// should avoid waiting for conditions that may take a very long time to complete, + /// as checking the condition is an overhead to every state change. + /// + Future> waitCondition( bool Function(St) condition, { int? timeoutInSeconds, }) async { var conditionTester = StoreTester.simple(this); try { - await conditionTester.waitCondition( + var info = await conditionTester.waitConditionGetLast( (TestInfo? info) => condition(info!.state), - timeoutInSeconds: timeoutInSeconds, + timeoutInSeconds: timeoutInSeconds ?? StoreTester.defaultTimeout, + ); + var action = info.action; + if (action == null) throw StoreExceptionTimeout(); + return action; + } finally { + await conditionTester.cancel(); + } + } + + /// Returns a future which will complete when an action of the given type is dispatched, and + /// then waits until it finishes. Ignores other actions types. + /// + /// You may also provide a [timeoutInSeconds], which by default is 10 minutes. + /// If you want, you can modify [StoreTester.defaultTimeout] to change the default timeout. + /// + /// This method returns the action, which you can use to check its `status`: + /// + /// ```dart + /// var action = await store.waitActionType(MyAction); + /// expect(action.status.originalError, isA()); + /// ``` + /// + /// You should only use this method in tests. + @visibleForTesting + Future> waitActionType( + Type actionType, { + int? timeoutInSeconds, + }) async { + var conditionTester = StoreTester.simple(this); + try { + var info = await conditionTester.waitUntil( + actionType, + timeoutInSeconds: timeoutInSeconds ?? StoreTester.defaultTimeout, + ); + var action = info.action; + if (action == null) throw StoreExceptionTimeout(); + return action; + } finally { + await conditionTester.cancel(); + } + } + + /// Returns a future which will complete when an action of ANY of the given types is dispatched, + /// and then waits until it finishes. Ignores other actions types. + /// + /// You may also provide a [timeoutInSeconds], which by default is 10 minutes. + /// If you want, you can modify [StoreTester.defaultTimeout] to change the default timeout. + /// + /// This method returns the action, which you can use to check its `status`: + /// + /// ```dart + /// var action = await store.waitAnyActionType([MyAction, OtherAction]); + /// expect(action.status.originalError, isA()); + /// ``` + /// + /// You should only use this method in tests. + @visibleForTesting + Future> waitAnyActionType( + List actionTypes, { + bool ignoreIni = true, + int? timeoutInSeconds, + }) async { + var conditionTester = StoreTester.simple(this); + try { + var info = await conditionTester.waitUntilAny( + actionTypes, + timeoutInSeconds: timeoutInSeconds ?? StoreTester.defaultTimeout, ); + var action = info.action; + if (action == null) throw StoreExceptionTimeout(); + return action; } finally { await conditionTester.cancel(); } diff --git a/lib/src/store_tester.dart b/lib/src/store_tester.dart index d6e1cd7..397e49c 100644 --- a/lib/src/store_tester.dart +++ b/lib/src/store_tester.dart @@ -19,8 +19,10 @@ typedef StateCondition = bool Function(TestInfo info); /// class StoreTester { // - /// The global default timeout for the wait functions. - static const defaultTimeout = 500; + /// The global default timeout for the wait functions is 10 minutes. + /// This value is not final and can be modified. + /// To disable the timeout, modify this to a large value, like 300000000 (almost 10 years). + static var defaultTimeout = 300000000; /// If the default debug info should be printed to the console or not. static bool printDefaultDebugInfo = true; @@ -203,8 +205,10 @@ class StoreTester { StateCondition condition, { bool testImmediately = true, bool ignoreIni = true, - int timeoutInSeconds = defaultTimeout, + int? timeoutInSeconds, }) async { + timeoutInSeconds ??= defaultTimeout; + var infoList = await waitCondition( condition, testImmediately: testImmediately, @@ -231,8 +235,10 @@ class StoreTester { StateCondition condition, { bool testImmediately = true, bool ignoreIni = true, - int? timeoutInSeconds = defaultTimeout, + int? timeoutInSeconds, }) async { + timeoutInSeconds ??= defaultTimeout; + TestInfoList infoList = TestInfoList(); if (testImmediately) { @@ -286,8 +292,10 @@ class StoreTester { Future> waitUntilErrorGetLast({ Object? error, Object? processedError, - int timeoutInSeconds = defaultTimeout, + int? timeoutInSeconds, }) async { + timeoutInSeconds ??= defaultTimeout; + var infoList = await waitUntilError( error: error, processedError: processedError, @@ -309,8 +317,10 @@ class StoreTester { Future> waitUntilError({ Object? error, Object? processedError, - int timeoutInSeconds = defaultTimeout, + int? timeoutInSeconds, }) async { + timeoutInSeconds ??= defaultTimeout; + assert(error != null || processedError != null); var condition = (TestInfo info) => @@ -345,11 +355,33 @@ class StoreTester { /// Future> waitUntil( Type actionType, { - int timeoutInSeconds = defaultTimeout, + int? timeoutInSeconds, }) async { + timeoutInSeconds ??= defaultTimeout; + TestInfo? testInfo; - while (testInfo == null || testInfo.type != actionType || testInfo.isINI) { + while ((testInfo == null) || (testInfo.type != actionType) || testInfo.isINI) { + testInfo = await _next(timeoutInSeconds: timeoutInSeconds); + } + + lastInfo = testInfo; + + return testInfo; + } + + /// Runs until an action of any of the given types is dispatched, and then waits until it finishes. + /// Returns the info after the action finishes. **Ignores other** actions types. + /// + Future> waitUntilAny( + List actionTypes, { + int? timeoutInSeconds, + }) async { + timeoutInSeconds ??= defaultTimeout; + + TestInfo? testInfo; + + while ((testInfo == null) || (!actionTypes.contains(testInfo.type)) || testInfo.isINI) { testInfo = await _next(timeoutInSeconds: timeoutInSeconds); } @@ -364,10 +396,12 @@ class StoreTester { Future> waitUntilAll( List actionTypes, { bool ignoreIni = true, - int timeoutInSeconds = defaultTimeout, + int? timeoutInSeconds, }) async { assert(actionTypes.isNotEmpty); + timeoutInSeconds ??= defaultTimeout; + TestInfoList infoList = TestInfoList(); Set actionsIni = Set.from(actionTypes); Set actionsEnd = {}; @@ -395,8 +429,10 @@ class StoreTester { Future> waitUntilAllGetLast( List actionTypes, { bool ignoreIni = true, - int timeoutInSeconds = defaultTimeout, + int? timeoutInSeconds, }) async { + timeoutInSeconds ??= defaultTimeout; + var infoList = await waitUntilAll( actionTypes, ignoreIni: ignoreIni, @@ -417,8 +453,10 @@ class StoreTester { /// Future> waitUntilAction( ReduxAction action, { - int timeoutInSeconds = defaultTimeout, + int? timeoutInSeconds, }) async { + timeoutInSeconds ??= defaultTimeout; + TestInfo? testInfo; while (testInfo == null || testInfo.action != action || testInfo.isINI) { @@ -471,15 +509,18 @@ class StoreTester { /// Future> waitAllUnorderedGetLast( List actionTypes, { - int timeoutInSeconds = defaultTimeout, + int? timeoutInSeconds, List? ignore, - }) async => - (await waitAllUnordered( - actionTypes, - timeoutInSeconds: timeoutInSeconds, - ignore: ignore, - )) - .last; + }) async { + timeoutInSeconds ??= defaultTimeout; + + return (await waitAllUnordered( + actionTypes, + timeoutInSeconds: timeoutInSeconds, + ignore: ignore, + )) + .last; + } /// Runs until **all** given actions types are dispatched, **in order**. /// Waits until all of them are finished. @@ -593,10 +634,13 @@ class StoreTester { /// Future> waitAllUnordered( List actionTypes, { - int timeoutInSeconds = defaultTimeout, + int? timeoutInSeconds, List? ignore, }) async { assert(actionTypes.isNotEmpty); + + timeoutInSeconds ??= defaultTimeout; + ignore ??= _ignore; // Actions which are expected can't also be ignored. @@ -698,8 +742,10 @@ class StoreTester { } Future> _next({ - int? timeoutInSeconds = defaultTimeout, + int? timeoutInSeconds, }) async { + timeoutInSeconds ??= defaultTimeout; + if (_futures.isEmpty) { _completer = Completer(); _futures.addLast(_completer.future); @@ -707,12 +753,10 @@ class StoreTester { var result = _futures.removeFirst(); - _currentTestInfo = await ((timeoutInSeconds == null) - ? result - : result.timeout( - Duration(seconds: timeoutInSeconds), - onTimeout: (() => throw StoreExceptionTimeout()), - )); + _currentTestInfo = await result.timeout( + Duration(seconds: timeoutInSeconds), + onTimeout: (() => throw StoreExceptionTimeout()), + ); return _currentTestInfo; } diff --git a/lib/src/view_model.dart b/lib/src/view_model.dart index 2e77d14..cdf0245 100644 --- a/lib/src/view_model.dart +++ b/lib/src/view_model.dart @@ -48,6 +48,51 @@ abstract class VmEquals { /// @immutable abstract class Vm { + // + + /// To test the view-model generated by a Factory, use [createFrom] and pass it the + /// [store] and the [factory]. Note this method must be called in a recently + /// created factory, as it can only be called once per factory instance. + /// + /// The method will return the view-model, which you can use to: + /// + /// * Inspect the view-model properties directly, or + /// + /// * Call any of the view-model callbacks. If the callbacks dispatch actions, + /// you use `await store.waitActionType(MyAction)`, + /// or `await store.waitAllActionTypes([MyAction, OtherAction])`, + /// or `await store.waitCondition((state) => ...)`, or if necessary you can even + /// record all dispatched actions and state changes with `Store.record.start()` + /// and `Store.record.stop()`. + /// + /// Example: + /// ``` + /// var store = Store(initialState: User("Mary")); + /// var vm = Vm.createFrom(store, MyFactory()); + /// + /// // Checking a view-model property. + /// expect(vm.user.name, "Mary"); + /// + /// // Calling a view-model callback and waiting for the action to finish. + /// vm.onChangeNameTo("Bill"); // Dispatches SetNameAction("Bill"). + /// await store.waitActionType(SetNameAction); + /// expect(store.state.name, "Bill"); + /// + /// // Calling a view-model callback and waiting for the state to change. + /// vm.onChangeNameTo("Bill"); // Dispatches SetNameAction("Bill"). + /// await store.waitCondition((state) => state.name == "Bill"); + /// expect(store.state.name, "Bill"); + /// ``` + /// + @visibleForTesting + static Model createFrom( + Store store, + VmFactory factory, + ) { + internalsVmFactoryInject(factory, store.state, store); + return internalsVmFactoryFromStore(factory) as Model; + } + /// The List of properties which will be used to determine whether two BaseModels are equal. final List equals; @@ -148,32 +193,6 @@ abstract class VmFactory { Model? fromStore(); - /// To test the view-model generated by a Factory, first create a store-tester. - /// Then call the [fromStoreTester] method, passing the store-tester. You will get - /// the view-model, which you can use to: - /// * Inspect the view-model properties directly, or - /// * Call any of the view-model callbacks. If the callbacks dispatch actions, - /// you can wait for them using the store-tester. - /// - /// Example: - /// ``` - /// var storeTester = StoreTester(initialState: User("Mary")); - /// var vm = MyFactory().fromStoreTester(storeTester); - /// expect(vm.user.name, "Mary"); - /// - /// vm.onChangeNameTo("Bill"); // Dispatches SetNameAction("Bill"). - /// var info = await storeTester.wait(SetNameAction); - /// expect(info.state.name, "Bill"); - /// ``` - /// Note: This method must be called in a recently created factory, as this - /// method may be called only once per factory instance. - /// - @visibleForTesting - Model? fromStoreTester(StoreTester storeTester) { - internalsVmFactoryInject(this, storeTester.state, storeTester.store); - return internalsVmFactoryFromStore(this) as Model; - } - final T? _connector; /// The connector widget that will instantiate the view-model. diff --git a/pubspec.yaml b/pubspec.yaml index a942638..0c6e501 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: async_redux description: Redux state management. An optimized Redux version, which is very easy to learn and use, yet powerful and tailored for Flutter. It allows both sync and async reducers. -version: 22.0.0-dev.3 +version: 22.0.0-dev.6 # author: Marcelo Glasberg homepage: https://github.com/marcglasberg/async_redux topics: