November 29, 2023 - 4 min
Migrating From StateNotifier to Notifier in Riverpod 2.0 With Unit Tests
Do you use Riverpod? Do you write unit tests for your notifiers? If you used the “good old” StateNotifier, wrote tests for it (as we did) and now want to switch to the new Notifier, but you’re having trouble writing tests for it, or you just want to start writing unit tests for Notifier, stay tuned.
In Flutter development, we use Riverpod as our state management solution and its StateNotifier class whenever we need to hold and update some state in the app. After any business logic or data handling code is written for some feature, it should be covered with unit tests to make sure the code behaves exactly as we intended.
To avoid going into details why unit testing is a must and how to start with it in the first place, you can check out our another blog post (TDD in Flutter With Example Application Using Riverpod and Firebase) talking in detail about unit testing, test driven development (TDD), all accompanied by a real example app.
As Riverpod in version 2.0 brought new providers and StateNotifier became obsolete with its further usage being discouraged in official documentation (from ‘StateNotifier’), we wanted to switch to a new recommended solution. The migration of the class itself was pretty straightforward but migrating unit tests was a little bit more challenging and this is what this blog post is all about.
If you have similar doubts with migrating from StateNotifier class and its unit test to Notifier, or just want to check out how you could write a unit test for your own Notifier class implementation, keep reading.
StateNotifier implementation and unit test
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../data/repository/auth_repository.dart';
import 'auth_state.dart';
final authStateNotifierProvider =
StateNotifierProvider<AuthStateNotifier, AuthState>(
(ref) => AuthStateNotifier(ref.watch(authRepositoryProvider)),
);
class AuthStateNotifier extends StateNotifier<AuthState> {
final AuthRepository _authRepository;
AuthStateNotifier(this._authRepository) : super(const AuthStateInitial());
Future<void> login({
required String email,
required String password,
}) async {
state = const AuthState.authenticating();
final result =
await _authRepository.login(email: email, password: password);
result.fold(
(failure) {
state = const AuthStateUnauthenticated();
},
(_) {
state = const AuthStateAuthenticated();
},
);
}
}
As it can be seen in the code snippet above, we have simple AuthStateNotifier which extends StateNotifier and holds AuthState (sealed class which is extended by AuthStateInitial, AuthStateAuthenticating, AuthStateAuthenticated and AuthStateUnauthenticated classes).
It depends on AuthRepository provided from StateNotifierProvider and in the login method it calls AuthRepository’s login method which returns Either type (from either_dart package), Left with Failure object in case login fails (or any other exception occurs) or empty Right if login succeeds.
Based on AuthRepository’s login method, AuthStateNotifier’s login method will either update its state to AuthStateUnauthenticated or AuthStateAuthenticated.
import 'package:either_dart/either.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:mocktail/mocktail.dart';
import 'package:myapp/features/auth/data/repository/auth_repository.dart';
import 'package:myapp/features/auth/domain/notifiers/auth_state.dart';
import 'package:myapp/features/auth/domain/notifiers/auth_state_notifier.dart';
import 'package:state_notifier_test/state_notifier_test.dart';
class MockAuthRepository extends Mock implements AuthRepository {}
void main() {
late AuthRepository authRepository;
late ProviderContainer providerContainer;
setUp(() {
authRepository = MockAuthRepository();
providerContainer = ProviderContainer(overrides: [
authStateNotifierProvider
.overrideWith((ref) => AuthStateNotifier(authRepository)),
]);
});
group('login({String email, String password})', () {
stateNotifierTest(
'executes success flow',
setUp: () {
when(() => authRepository.login(email: 'email@example.com', password: 'password'))
.thenAnswer((_) async => const Right(null));
},
build: () => providerContainer.read(authStateNotifierProvider.notifier),
actions: (stateNotifier) async {
await stateNotifier.login(email: 'email@example.com', password: 'password');
},
expect: () =>
[const AuthStateAuthenticating(), const AuthStateAuthenticated()],
);
});
}
Our unit test for AuthStateNotifier’s login method was implemented with the state_notifier_test package (mentioned also in more details in our previous blog post mentioned above) which provides some convenient callbacks (setUp, build, actions and expect) to structure the unit test easily. For mocking external dependencies, in this case AuthRepository, we used mocktail instead of mockito because it doesn’t require any code generation in its setup process.
Migrating StateNotifier to Notifier
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../data/repository/auth_repository.dart';
import 'auth_state.dart';
final authNotifierProvider = NotifierProvider<AuthNotifier, AuthState>(
() => AuthNotifier(),
);
class AuthNotifier extends Notifier<AuthState> {
late AuthRepository _authRepository;
@override
AuthState build() {
_authRepository = ref.watch(authRepositoryProvider);
return const AuthStateInitial();
}
Future<void> login({
required String email,
required String password,
}) async {
state = const AuthState.authenticating();
final result =
await _authRepository.login(email: email, password: password);
result.fold(
(failure) {
state = const AuthStateUnauthenticated();
},
(_) {
state = const AuthStateAuthenticated();
},
);
}
}
In the above code snippet you can see how we implemented the same functionality from AuthStateNotifier in AuthNotifier which extends the new Notifier class. The main difference is that Notifier doesn’t receive anything in the constructor but needs to override the build method which has to return the AuthState object in this case and this build method is the new proper place to instantiate any external dependencies, in this case AuthRepository. Login method stayed completely the same.
Now we tried to write a new test for AuthNotifier but realized we will have to come up with another approach because the state_notifier_test package works only with StateNotifier.
To make the migration from our StateNotifier test to Notifier test easier, first we refactored our existing StateNotifier test to use only Flutter test methods as it can be seen in the following code snippet.
Refactoring StateNotifier test and writing Notifier unit test
import 'package:either_dart/either.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:mocktail/mocktail.dart';
import 'package:myapp/features/auth/data/repository/auth_repository.dart';
import 'package:myapp/features/auth/domain/notifiers/auth_state.dart';
import 'package:myapp/features/auth/domain/notifiers/auth_state_notifier.dart';
class MockAuthRepository extends Mock implements AuthRepository {}
void main() {
late AuthRepository authRepository;
late ProviderContainer providerContainer;
setUp(() {
authRepository = MockAuthRepository();
providerContainer = ProviderContainer(overrides: [
authStateNotifierProvider
.overrideWith((ref) => AuthStateNotifier(authRepository)),
]);
});
group('login({String email, String password})', () {
test(
'executes success flow',
() async {
when(() => authRepository.login(email: 'email@example.com', password: 'password'))
.thenAnswer((_) async => const Right(null));
final authStates = <AuthState>[];
final authStateNotifier =
providerContainer.read(authStateNotifierProvider.notifier);
authStateNotifier.addListener(
(state) => authStates.add(state),
fireImmediately: false,
);
await authStateNotifier.login(email: 'email@example.com', password: 'password');
expect(
[
const AuthStateAuthenticating(),
const AuthStateAuthenticated(),
],
authStates,
);
},
);
});
}
When we removed the state_notifier_test package, we had to manually collect AuthStateNotifier’s state changes in one simple list by listening to state changes via StateNotifier’s addListener method. Everything else could almost stay the same, we just didn’t have stateNotifierTest’s method callbacks anymore, but had to write everything in the body of Flutter’s test method. With this refactor finished, it was a way clearer what needs to be done to write a working unit test for our new AuthNotifier’s login method.
Instead of calling the addListener method on the StateNotifier instance, the Notifier class doesn’t have an addListener method but we could utilize the listen method directly on the ProviderContainer instance (similar to WidgetRef or Ref listen method which are used more often in the code). fireImmediately parameter serves if we want to get the initial state that was set before we called the listen method, in this case we don’t need it and can be set as false.
All the rest stays the same and that was everything needed to be done to finish the migration from StateNotifier to Notifier and its accompanying unit test.
Conclusion
In a few chapters and code snippets, we showed what was necessary to migrate StateNotifier class implementation and its unit test to Notifier. Regarding Notifier and its unit test, the same would apply also for AsyncNotifier, the only difference is that there, you would update AsyncValue instead of custom state, in our case, AuthState.
Give Kudos by sharing the post!