Advanced State Management (Bloc & Riverpod)

Testing Riverpod Providers and Notifiers

16 min Lesson 13 of 14

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.

Note: Add 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);
});
Tip: Always call 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.

Warning: Avoid calling 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 ProviderContainer with overrides to unit-test notifiers in isolation.
  • Assert AsyncValue loading, data, and error states by collecting emissions via container.listen.
  • Use ProviderScope(overrides: [...]) to inject fakes in widget tests without changing production code.
  • Always dispose containers (unit tests) and use addTearDown to keep tests hermetic.
  • Verify both the happy path (loading → data) and the error path (loading → error) to ensure robust UIs.