fluttertestingriverpod

How to properly test in Flutter Riverpod?


I am working on personal flutter project using Riverpod without hooks or code generation. I just implemented localization for the app. The implementation consists of single button that when pressed changes the current language to the other language due to using only two languages in the app.I used AutoDisposeAsyncNotifier to change the state of the app localization and to save locally via Shared Preferences the current locale. I would like to make unit tests for the logic, but I am struggling with understanding from the riverpod documentation how to properly test the AsyncNotifier/Provider that I am using.

Here is the content of the localization notifier I implemented:

class LocalizationNotifier extends AutoDisposeAsyncNotifier<Locale> {
  Locale currentLocale = const Locale('en');

  @override
  FutureOr<Locale> build() {
    return SharedPreferencesService().getLocale();
  }

  void changeLocale(Locale newLocale) {
    switch(currentLocale.languageCode) {
      case 'en':
        newLocale = const Locale('bg');
        break;
      case 'bg':
        newLocale = const Locale('en');
        break;
      default:
        newLocale = const Locale('en');
        break;
    }
    currentLocale = newLocale;
    SharedPreferencesService().setLocale(newLocale);

    ref.invalidateSelf();
  }
}

final localizationProvider = AsyncNotifierProvider.autoDispose<LocalizationNotifier ,Locale>(
  LocalizationNotifier.new,
);

Here is the code of the shared preferences service methods that I am using in the notifier I want to test:

Future<void> setLocale(Locale locale) async {
    switch(locale.languageCode){
      case 'en': await _prefs.setString(SharedPreferencesKeys.locale, 'en');
      break;
      case 'bg': await _prefs.setString(SharedPreferencesKeys.locale, 'bg');
      break;
      default: await _prefs.setString(SharedPreferencesKeys.locale, 'en');
      break;
    }
  }

  Locale getLocale() {
    String languageCode = _prefs.getString(SharedPreferencesKeys.locale) ?? 'en';
    return Locale(languageCode);
  }

I've been banging my head over the documentation even tried using chatGPT but to no avail to understand how to do it and why to do it the way it should be. If anyone could provide me with example on on how to test the notifier so I could reverse engineer it for all the future providers/notifiers I implement that would be nice.


Solution

  • After some research on flutter app architectures, design patterns and testing overall i've reached the conclusion that the main issue is in the structure of my flutter application. According to the Riverpod documentation testing Notifiers is not recommended and instead the services should be implemented in such a way and abstraction that their methods and functionallity must be tested. Now to fix my code i refactored to implement dependency injection in order to make the methods more testable and bypass testing the Notifier.

    Keep in mind after asking the question originally i refactored my code to use code generation so the answer will be using riverpod's code generation

    Repository class code:

    part 'shared_preferences_repository.g.dart';
    
    class SharedPreferencesRepository {
      final SharedPreferences _sharedPrefs;
    
      SharedPreferencesRepository(this._sharedPrefs);
    
      Future<void> setLocale(Locale locale) async {
        await _sharedPrefs.setString(SharedPreferencesKeys.locale, locale.languageCode);
      }
    
      Locale getLocale() {
        String languageCode = _sharedPrefs.getString(SharedPreferencesKeys.locale) ?? 'en';
        return Locale(languageCode);
      }
    }
    
    class SharedPreferencesKeys {
      static const String locale = 'locale';
    }
    
    @riverpod
    SharedPreferencesRepository sharedPreferences(SharedPreferencesRef ref) {
      throw UnimplementedError();
    }
    

    I am implementing the sharedPreferencesProvider because i will do by overriding it in the main method were it is innitialized:

    void main() async {
      WidgetsFlutterBinding.ensureInitialized();
      final sharedPreferences = await SharedPreferences.getInstance();
    
      runApp(
        ProviderScope(
          overrides: [
            sharedPreferencesProvider.overrideWithValue(
              SharedPreferencesRepository(sharedPreferences),
            ),
          ],
          child: const MyApp(),
        ),
      );
    }
    

    The Controller was refactored to look like that:

    part 'localization_controller.g.dart';
    
    @riverpod
    class LocalizationController extends _$LocalizationController {
      late SharedPreferencesRepository _sharedPrefs;
    
      @override
      Locale build() {
        _sharedPrefs = ref.read(sharedPreferencesProvider);
        return _sharedPrefs.getLocale();
      }
    
      void changeLocale() async {
        final locale = _determineNewLocale(state);
    
        await _sharedPrefs.setLocale(locale);
        state = _sharedPrefs.getLocale();
      }
    
      Locale _determineNewLocale(Locale locale) {
        switch (locale.languageCode) {
          case 'en':
            return const Locale('bg');
          case 'bg':
            return const Locale('en');
          default:
            return locale;
        }
      }
    }
    

    And finally the tests:

    ProviderContainer createContainer({
      ProviderContainer? parent,
      List<Override> overrides = const [],
      List<ProviderObserver>? observers,
    }) {
      final container = ProviderContainer(
        parent: parent,
        overrides: overrides,
        observers: observers,
      );
    
      addTearDown(container.dispose);
    
      return container;
    }
    
    void main() {
      // Declaring variables
      late ProviderContainer container;
    
      final Map<String, Object> initialLocale = <String, Object>{SharedPreferencesKeys.locale: 'en'};
    
      //Setup
      setUp(() async {
        TestWidgetsFlutterBinding.ensureInitialized();
        SharedPreferences.setMockInitialValues(initialLocale);
        final sharedPrefs = await SharedPreferences.getInstance();
    
        container = createContainer(
          overrides: [sharedPreferencesProvider.overrideWithValue(SharedPreferencesRepository(sharedPrefs))],
        );
      });
    
      //Tests
      group("Localization Repository Tests", (){
        test("Testing getLocale on initial value.", () {
          expect(container.read(sharedPreferencesProvider).getLocale(), const Locale('en'));
        });
    
        test("Testing Changing the Locale.", () async {
          await container.read(sharedPreferencesProvider).setLocale(const Locale('bg'));
          expect(container.read(sharedPreferencesProvider).getLocale(), const Locale('bg'));
        });
    
        test("Testing Changing the Locale multiple times.", () async {
          await container.read(sharedPreferencesProvider).setLocale(const Locale('bg'));
          expect(container.read(sharedPreferencesProvider).getLocale(), const Locale('bg'));
    
          await container.read(sharedPreferencesProvider).setLocale(const Locale('en'));
          expect(container.read(sharedPreferencesProvider).getLocale(), const Locale('en'));
        });
      });
    }
    

    As it can be seen i am testing the repository class methods via their provider instead of the notifier.