Testing Riverpod in Flutter: ProviderContainer, Overrides, and AsyncNotifier Tests
Testing Riverpod providers means working with ProviderContainer in unit tests and ProviderScope overrides in widget tests. You can stub async providers, replace repositories with fakes, and assert on state transitions — without running the full app.
Key Takeaways
Use ProviderContainer for pure unit tests. It gives you a lightweight scope to read and watch providers without Flutter's widget tree.
Override providers to inject fakes. Pass overrides: [myProvider.overrideWithValue(fakeImpl)] to replace real implementations in tests.
container.read(provider) vs container.listen(). Use read for one-shot values; use listen to track state transitions over time.
AsyncNotifier tests need container.read(provider.future). Await the future to ensure the provider has loaded before asserting.
ProviderScope overrides work in widget tests. Wrap your widget in ProviderScope(overrides: [...]) to replace providers for integration-style widget tests.
Why Riverpod Tests Are Different
Riverpod manages state outside the widget tree — which is good for testability. A Provider, FutureProvider, or AsyncNotifier can be tested with zero Flutter widgets using just a ProviderContainer.
Most Flutter state management solutions require you to build a widget to test state. Riverpod doesn't.
Unit Testing With ProviderContainer
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
final greetingProvider = Provider<String>((ref) => 'Hello, World!');
void main() {
test('greetingProvider returns expected string', () {
final container = ProviderContainer();
addTearDown(container.dispose);
final value = container.read(greetingProvider);
expect(value, 'Hello, World!');
});
}Always call container.dispose() in addTearDown — leaked containers cause test interference.
Overriding Providers
The power of Riverpod testing is provider overrides. Swap a real repository for a fake:
final userRepositoryProvider = Provider<UserRepository>((ref) {
return RealUserRepository();
});
final currentUserProvider = FutureProvider<User>((ref) async {
final repo = ref.watch(userRepositoryProvider);
return repo.fetchCurrentUser();
});
class FakeUserRepository implements UserRepository {
@override
Future<User> fetchCurrentUser() async => User(id: '1', name: 'Alice');
}
void main() {
test('currentUserProvider returns user from repository', () async {
final container = ProviderContainer(
overrides: [
userRepositoryProvider.overrideWithValue(FakeUserRepository()),
],
);
addTearDown(container.dispose);
final user = await container.read(currentUserProvider.future);
expect(user.name, 'Alice');
});
}Testing AsyncNotifier
AsyncNotifier (Riverpod 2.x) is the modern pattern for async state:
class TodoListNotifier extends AsyncNotifier<List<Todo>> {
@override
Future<List<Todo>> build() async {
return ref.watch(todoRepositoryProvider).fetchTodos();
}
Future<void> addTodo(String title) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final todos = await future;
final newTodo = await ref.read(todoRepositoryProvider).create(title);
return [...todos, newTodo];
});
}
}
final todoListProvider = AsyncNotifierProvider<TodoListNotifier, List<Todo>>(
TodoListNotifier.new,
);Test it:
void main() {
test('addTodo appends item to list', () async {
final fakeTodos = [Todo(id: '1', title: 'Existing')];
final fakeRepo = FakeTodoRepository(todos: fakeTodos);
final container = ProviderContainer(
overrides: [
todoRepositoryProvider.overrideWithValue(fakeRepo),
],
);
addTearDown(container.dispose);
// Wait for initial load
await container.read(todoListProvider.future);
// Perform action
await container.read(todoListProvider.notifier).addTodo('New Task');
// Assert
final todos = await container.read(todoListProvider.future);
expect(todos.length, 2);
expect(todos.last.title, 'New Task');
});
}Tracking State Transitions With listen
To assert that a provider passes through intermediate states:
test('provider goes through loading then data states', () async {
final container = ProviderContainer(
overrides: [todoRepositoryProvider.overrideWithValue(FakeTodoRepository())],
);
addTearDown(container.dispose);
final states = <AsyncValue<List<Todo>>>[];
container.listen<AsyncValue<List<Todo>>>(
todoListProvider,
(previous, next) => states.add(next),
fireImmediately: true,
);
await container.read(todoListProvider.future);
expect(states.first, isA<AsyncLoading>());
expect(states.last, isA<AsyncData>());
});fireImmediately: true captures the initial state before any transitions.
Widget Tests With ProviderScope Overrides
For widget tests that involve Riverpod providers, use ProviderScope:
testWidgets('displays user name after loading', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
userRepositoryProvider.overrideWithValue(FakeUserRepository()),
],
child: const MaterialApp(home: UserProfilePage()),
),
);
// Initial loading state
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// Wait for async provider to resolve
await tester.pumpAndSettle();
expect(find.text('Alice'), findsOneWidget);
});Testing StateNotifier (Riverpod 1.x)
If you're on Riverpod 1.x with StateNotifier:
class CounterNotifier extends StateNotifier<int> {
CounterNotifier() : super(0);
void increment() => state++;
}
final counterProvider = StateNotifierProvider<CounterNotifier, int>(
(ref) => CounterNotifier(),
);
void main() {
test('increment updates state', () {
final container = ProviderContainer();
addTearDown(container.dispose);
expect(container.read(counterProvider), 0);
container.read(counterProvider.notifier).increment();
expect(container.read(counterProvider), 1);
});
}Error State Testing
Test that your UI handles AsyncError correctly:
class FailingRepository implements UserRepository {
@override
Future<User> fetchCurrentUser() async {
throw Exception('Network error');
}
}
testWidgets('shows error message on failure', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
userRepositoryProvider.overrideWithValue(FailingRepository()),
],
child: const MaterialApp(home: UserProfilePage()),
),
);
await tester.pumpAndSettle();
expect(find.text('Something went wrong'), findsOneWidget);
});Dependency Chain Testing
Riverpod providers can depend on other providers. Test the whole chain by overriding the root dependency:
// Chain: userProvider → userRepositoryProvider → httpClientProvider
final httpClientProvider = Provider<HttpClient>((ref) => HttpClient());
final userRepositoryProvider = Provider<UserRepository>(
(ref) => UserRepository(ref.watch(httpClientProvider)),
);
final userProvider = FutureProvider<User>(
(ref) => ref.watch(userRepositoryProvider).fetchCurrentUser(),
);
void main() {
test('chain resolves with fake http client', () async {
final container = ProviderContainer(
overrides: [
httpClientProvider.overrideWithValue(FakeHttpClient()),
],
);
addTearDown(container.dispose);
final user = await container.read(userProvider.future);
expect(user.id, isNotEmpty);
});
}Override the bottom of the dependency chain and the entire chain gets the fake.
Key Packages
| Package | Purpose |
|---|---|
flutter_riverpod |
Core Riverpod for Flutter |
riverpod_annotation |
Code generation for providers |
mocktail |
Mocking for Dart (works well with Riverpod) |
flutter_test |
Widget testing framework |
Common Mistakes
Forgetting addTearDown(container.dispose) — containers that aren't disposed leak between tests, causing false passes or failures.
Reading instead of listening for state transitions — container.read() returns the current value. To assert intermediate states, use container.listen().
Not awaiting .future on async providers — container.read(asyncProvider) returns AsyncValue<T>, not T. Use container.read(asyncProvider.future) to get the resolved value.
Using overrideWithProvider instead of overrideWithValue — overrideWithValue is for injecting an instance directly. overrideWithProvider is for replacing the provider factory itself.
Running Riverpod Tests
flutter test test/providers/
flutter test --name "addTodo"
flutter test --coverageRiverpod's architecture makes test coverage straightforward — every Provider, FutureProvider, and Notifier can be tested in isolation. Start with the business logic providers, then wire up widget tests for the UI layer.