Question #123MediumState Management

Know right proper code structure - feature first structure with riverpod state management and writing test case with it.

#state#riverpod

Answer

Overview

This covers the recommended feature-first folder structure with Riverpod state management and how to write test cases alongside it.


Feature-First Folder Structure with Riverpod

text
lib/
├── core/
│   ├── di/                    ← Dependency injection
│   ├── network/               ← Dio, API client
│   ├── theme/                 ← App theme
│   └── utils/                 ← Helpers, extensions
├── features/
│   ├── auth/
│   │   ├── data/
│   │   │   ├── datasources/   ← Remote + local
│   │   │   ├── models/        ← JSON models
│   │   │   └── repositories/  ← Repo implementations
│   │   ├── domain/
│   │   │   ├── entities/      ← Pure Dart classes
│   │   │   ├── repositories/  ← Abstract interfaces
│   │   │   └── usecases/      ← Business logic
│   │   └── presentation/
│   │       ├── providers/     ← Riverpod providers
│   │       ├── screens/       ← UI screens
│   │       └── widgets/       ← Reusable widgets
│   │
│   └── products/              ← Same structure
└── main.dart

Riverpod State Management Setup

dart
// features/auth/presentation/providers/auth_provider.dart
final authRepositoryProvider = Provider<AuthRepository>((ref) {
  return AuthRepositoryImpl(
    remoteDataSource: ref.watch(authRemoteDataSourceProvider),
  );
});

final loginUseCaseProvider = Provider((ref) {
  return LoginUseCase(ref.watch(authRepositoryProvider));
});

final authStateProvider = AsyncNotifierProvider<AuthNotifier, AuthState>(
  AuthNotifier.new,
);

class AuthNotifier extends AsyncNotifier<AuthState> {
  
  Future<AuthState> build() async => AuthState.initial();

  Future<void> login(String email, String password) async {
    state = AsyncValue.loading();
    final result = await ref.read(loginUseCaseProvider).call(email, password);
    state = result.fold(
      (failure) => AsyncValue.error(failure, StackTrace.current),
      (user) => AsyncValue.data(AuthState.authenticated(user)),
    );
  }
}

Test Structure

text
test/
├── features/
│   ├── auth/
│   │   ├── data/
│   │   │   └── repositories/
│   │   │       └── auth_repository_test.dart
│   │   ├── domain/
│   │   │   └── usecases/
│   │   │       └── login_usecase_test.dart
│   │   └── presentation/
│   │       └── providers/
│   │           └── auth_provider_test.dart
│   └── products/
│       └── ...
└── test_helpers/
    └── mock_providers.dart   ← Shared mocks

Writing Tests with Riverpod

dart
// test/features/auth/presentation/providers/auth_provider_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mocktail/mocktail.dart';

class MockAuthRepository extends Mock implements AuthRepository {}

void main() {
  group('AuthNotifier', () {
    late ProviderContainer container;
    late MockAuthRepository mockRepo;

    setUp(() {
      mockRepo = MockAuthRepository();
      container = ProviderContainer(
        overrides: [
          authRepositoryProvider.overrideWithValue(mockRepo),
        ],
      );
    });

    tearDown(() => container.dispose());

    test('login success emits authenticated state', () async {
      when(() => mockRepo.signIn(any(), any()))
          .thenAnswer((_) async => User(id: '1', email: 'test@test.com'));

      await container.read(authStateProvider.notifier).login('test@test.com', 'pass');

      final state = container.read(authStateProvider);
      expect(state, isA<AsyncData<AuthState>>());
      expect(state.value!.isAuthenticated, true);
    });

    test('login failure emits error state', () async {
      when(() => mockRepo.signIn(any(), any()))
          .thenThrow(ServerException('Invalid credentials'));

      await container.read(authStateProvider.notifier).login('bad@test.com', 'wrong');

      final state = container.read(authStateProvider);
      expect(state, isA<AsyncError>());
    });
  });
}

UseCase Test

dart
// test/features/auth/domain/usecases/login_usecase_test.dart
void main() {
  late LoginUseCase useCase;
  late MockAuthRepository mockRepo;

  setUp(() {
    mockRepo = MockAuthRepository();
    useCase = LoginUseCase(mockRepo);
  });

  test('returns user on valid credentials', () async {
    final user = User(id: '1', email: 'test@test.com');
    when(() => mockRepo.signIn(any(), any())).thenAnswer((_) async => Right(user));

    final result = await useCase('test@test.com', 'password');

    expect(result, Right(user));
    verify(() => mockRepo.signIn('test@test.com', 'password')).called(1);
  });
}

Key Principle: Feature-first = all layers of a feature live together. Riverpod providers are the glue. Tests mirror the same folder structure with mocked dependencies injected via

text
ProviderContainer
overrides.