unit-testingdartasynchronousriverpoddart-test

Dart Testing with Riverpod StateNotifierProvider and AsyncValue as state


This is my first app with Dart/Flutter/Riverpod, so any advice or comment about the code is welcome.

I'm using Hive as embedded db so the initial value for the provider's state is loaded asynchronously and using an AsyncValue of riverpod to wrapped it.

The following code works but I've got some doubts about the testing approach, so I would like to confirm if I'm using the Riverpod lib as It supposed to be used.

This is my provider with its deps (Preferences is a HiveObject to store app general config data):

final hiveProvider = FutureProvider<HiveInterface>((ref) async {
  return await App.setUp();
});

final prefBoxProvider = FutureProvider<Box<Preferences>>((ref) async {
  final HiveInterface hive = await ref.read(hiveProvider.future);
  return hive.openBox<Preferences>("preferences");
});

class PreferencesNotifier extends StateNotifier<AsyncValue<Preferences>> {
  late Box<Preferences> prefBox;

  PreferencesNotifier(Future<Box<Preferences>> prefBoxFuture): super(const AsyncValue.loading()) {
    prefBoxFuture.then((value) {
      prefBox = value;
      _loadCurrentPreferences();
    });
  }

  void _loadCurrentPreferences() {
    Preferences pref = prefBox.get(0) ?? Preferences();
    state = AsyncValue.data(pref);    
  }

  Future<void> save(Preferences prefs) async {    
    await prefBox.put(0, prefs);
    state = AsyncValue.data(prefs);
  }

  Preferences? get preferences {    
    return state.when(data: (value) => value,
    error: (_, __) => null,
    loading: () => null);
  }

}


final preferencesProvider = StateNotifierProvider<PreferencesNotifier, AsyncValue<Preferences>>((ref) {
  return PreferencesNotifier(ref.read(prefBoxProvider.future));
});

And the following is the test case, I'm mocking the Hive box provider (prefBoxProvider):

class Listener extends Mock {
  void call(dynamic previous, dynamic value);
}

Future<Box<Preferences>> prefBoxTesting() async {
  final hive = await App.setUp();
  Box<Preferences> box = await hive.openBox<Preferences>("testing_preferences");
  await box.clear();
  return box;
}

void main() {
  
  test('Preferences value changes', () async {

    final container = ProviderContainer(overrides: [
        prefBoxProvider.overrideWithValue(AsyncValue.data(await prefBoxTesting()))
    ],);
    addTearDown(() {
      container.dispose();
      Hive.deleteBoxFromDisk("testing_preferences");
    });
    final listener = Listener();

    container.listen<AsyncValue<Preferences>>(
      preferencesProvider,
      listener,
      fireImmediately: true,
    );
    verify(listener(null, const TypeMatcher<AsyncLoading>())).called(1);
    verifyNoMoreInteractions(listener);
    // Next line waits until we have a value for preferences attribute
    await container.read(preferencesProvider.notifier).stream.first;
    verify(listener(const TypeMatcher<AsyncLoading>(), const TypeMatcher<AsyncData>())).called(1);
    
    Preferences preferences = Preferences.from(container.read(preferencesProvider.notifier).preferences!);
    
    preferences.currentListName = 'Lista1';
    await container.read(preferencesProvider.notifier).save(preferences);
    
    verify(listener(const TypeMatcher<AsyncData>(), const TypeMatcher<AsyncData>())).called(1);
    verifyNoMoreInteractions(listener);
    final name = container.read(preferencesProvider.notifier).preferences!.currentListName;
    expect(name, equals('Lista1'));
   });

}

I've used as reference the official docs about testing Riverpod and the GitHub issue related with AsyncValues

Well, I found some problems to verify that the listener is called with the proper values, I used the TypeMatcher just to verify that the state instance has got the proper type and I check ("manually") the value of the wrapped object's attribute if It's the expected one. Is there a better way to achieve this ?

Finally, I didn't find too many examples with StateNotifier and AsyncValue as state type, Is there a better approach to implement providers that are initialized with deferred data ?


Solution

  • I didn't like too much my original approach so I created my own Matcher to compare wrapped values in AsyncValue instances:

    class IsWrappedValueEquals extends Matcher {
      final dynamic value;
    
      IsWrappedValueEquals(this.value);
    
      @override
      bool matches(covariant AsyncValue actual, Map<dynamic, dynamic> matchState) => 
        equals(actual.value).matches(value, matchState);
    
      @override
      Description describe(Description description) => description.add('Is wrapped value equals');
    }
    
    

    In the test, the final part is a bit different:

        Preferences preferences = Preferences.from(container.read(preferencesProvider.notifier).preferences!);
        
        preferences.currentListName = 'Lista1';
        await container.read(preferencesProvider.notifier).save(preferences);
    
    // the following line is the new one
        verify(listener(IsWrappedValueEquals(Preferences()), IsWrappedValueEquals(preferences))).called(1);
        verifyNoMoreInteractions(listener);
    }
    

    I prefer my custom Matcher to the original code, but I feel that there are too many custom code to test something, apparently, common.

    If anyone can tell me a better solution for this case, It'd be great.