Testing Riverpod Providers and Notifiers
Testing Riverpod Providers and Notifiers
Writing tests for Riverpod-based code is one of the most powerful advantages of the framework. Because providers are pure Dart objects and dependencies are injected through the container, you can override any provider with a fake or stub, isolate units of logic cleanly, and verify AsyncValue state transitions without spinning up a real backend.
This lesson covers unit-testing Notifier and AsyncNotifier classes using ProviderContainer, asserting AsyncValue loading/data/error sequences, and writing widget tests that swap real providers for controlled fakes using ProviderScope overrides.
flutter_riverpod and riverpod to your pubspec.yaml under both dependencies and dev_dependencies as needed. The ProviderContainer class lives in the core riverpod package and does not require Flutter, making it ideal for pure-Dart unit tests.Unit-Testing with ProviderContainer
ProviderContainer is the low-level object that owns and evaluates providers. In tests you create one manually, optionally passing overrides to replace real dependencies with fakes, then read state directly without any widget tree.
Basic ProviderContainer Test Setup
// counter_notifier.dart
import 'package:riverpod/riverpod.dart';
class CounterNotifier extends Notifier<int> {
@override
int build() => 0;
void increment() => state++;
void decrement() => state--;
void reset() => state = 0;
}
final counterProvider = NotifierProvider<CounterNotifier, int>(
CounterNotifier.new,
);
// counter_notifier_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod/riverpod.dart';
void main() {
group('CounterNotifier', () {
late ProviderContainer container;
setUp(() {
// Create a fresh container before every test
container = ProviderContainer();
});
tearDown(() {
// Always dispose to prevent memory leaks
container.dispose();
});
test('initial state is 0', () {
expect(container.read(counterProvider), 0);
});
test('increment increases state by 1', () {
container.read(counterProvider.notifier).increment();
expect(container.read(counterProvider), 1);
});
test('decrement below zero is allowed', () {
container.read(counterProvider.notifier).decrement();
expect(container.read(counterProvider), -1);
});
test('reset returns state to 0', () {
container.read(counterProvider.notifier).increment();
container.read(counterProvider.notifier).increment();
container.read(counterProvider.notifier).reset();
expect(container.read(counterProvider), 0);
});
});
}
Overriding Dependencies in Tests
The real power of ProviderContainer is dependency injection through overrides. Suppose your notifier depends on a repository provider. In tests you replace the real repository with a fake that returns predictable data — no HTTP calls, no database, no timers.
Overriding a Repository Provider
// user_repository.dart
abstract class UserRepository {
Future<String> fetchUsername(int id);
}
final userRepositoryProvider = Provider<UserRepository>((ref) {
throw UnimplementedError('Provide a real implementation');
});
// user_notifier.dart
class UserNotifier extends AsyncNotifier<String> {
@override
Future<String> build() async {
final repo = ref.watch(userRepositoryProvider);
return repo.fetchUsername(1);
}
}
final userProvider = AsyncNotifierProvider<UserNotifier, String>(
UserNotifier.new,
);
// user_notifier_test.dart
class FakeUserRepository implements UserRepository {
final String name;
FakeUserRepository(this.name);
@override
Future<String> fetchUsername(int id) async => name;
}
void main() {
test('userProvider loads username from repository', () async {
final container = ProviderContainer(
overrides: [
userRepositoryProvider.overrideWithValue(
FakeUserRepository('Alice'),
),
],
);
addTearDown(container.dispose);
// Wait for the async build to finish
final result = await container.read(userProvider.future);
expect(result, 'Alice');
});
}
Verifying AsyncValue Transitions
AsyncValue<T> has three states: AsyncLoading, AsyncData<T>, and AsyncError. When testing async notifiers you often want to assert the full sequence — loading first, then data (or error). Use a ProviderSubscription to capture every emission.
Asserting AsyncValue State Transitions
test('shows loading then data', () async {
final container = ProviderContainer(
overrides: [
userRepositoryProvider.overrideWithValue(
FakeUserRepository('Bob'),
),
],
);
addTearDown(container.dispose);
final states = <AsyncValue<String>>[];
final sub = container.listen<AsyncValue<String>>(
userProvider,
(previous, next) => states.add(next),
fireImmediately: true,
);
// Let the future complete
await container.read(userProvider.future);
sub.close();
// First emission is AsyncLoading, last is AsyncData
expect(states.first, isA<AsyncLoading<String>>());
expect(states.last, isA<AsyncData<String>>());
expect(states.last.value, 'Bob');
});
test('emits AsyncError when repository throws', () async {
final container = ProviderContainer(
overrides: [
userRepositoryProvider.overrideWith(
(ref) => throw Exception('network error'),
),
],
);
addTearDown(container.dispose);
final value = await container.read(userProvider.future).catchError((_) => '');
final state = container.read(userProvider);
expect(state, isA<AsyncError<String>>());
expect((state as AsyncError).error.toString(), contains('network error'));
});
Widget Tests with ProviderScope Overrides
For widget tests, wrap the widget under test in a ProviderScope and pass the same overrides list. This ensures every provider read inside the widget tree returns your controlled fake values instead of live implementations.
Widget Test Using ProviderScope Overrides
// user_screen.dart (simplified)
class UserScreen extends ConsumerWidget {
const UserScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userAsync = ref.watch(userProvider);
return userAsync.when(
loading: () => const CircularProgressIndicator(),
error: (e, _) => Text('Error: $e'),
data: (name) => Text('Hello, $name'),
);
}
}
// user_screen_test.dart
testWidgets('shows username after load', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
userRepositoryProvider.overrideWithValue(
FakeUserRepository('Carol'),
),
],
child: const MaterialApp(home: UserScreen()),
),
);
// Frame 1: async build starts — loading indicator is present
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// Resolve all pending futures and rebuild
await tester.pumpAndSettle();
expect(find.text('Hello, Carol'), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsNothing);
});
testWidgets('shows error message on failure', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
userRepositoryProvider.overrideWith(
(ref) => throw Exception('timeout'),
),
],
child: const MaterialApp(home: UserScreen()),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Error:'), findsOneWidget);
});
addTearDown(container.dispose) in unit tests and let the widget-test framework dispose the ProviderScope automatically. Forgetting to dispose a container leaks listeners and can cause test interference when running the full suite.Testing Notifier Methods That Trigger Async Side Effects
When a notifier method calls an async repository method and updates state, use container.listen with fireImmediately: true to capture every transition, then call the method and await the resulting future exposed via the .future sub-provider.
container.read(provider) on an AsyncNotifierProvider before the provider has been listened to at least once — the build method runs lazily. Access .future or subscribe with container.listen to trigger the first build.Summary
Riverpod's architecture makes testing straightforward:
- Use
ProviderContainerwithoverridesto unit-test notifiers in isolation. - Assert
AsyncValueloading, data, and error states by collecting emissions viacontainer.listen. - Use
ProviderScope(overrides: [...])to inject fakes in widget tests without changing production code. - Always dispose containers (unit tests) and use
addTearDownto keep tests hermetic. - Verify both the happy path (loading → data) and the error path (loading → error) to ensure robust UIs.