TDD in Flutter With Example Application Using Riverpod and Firebase

Flexible, maintainable, and easy to extend codebase ensures the application you are building is of high quality. One of the best ways to achieve that is development by using the TDD approach.

Test-Driven Development is a software development approach that is based on writing testing code scenarios before implementing the actual code. By doing so proper specifications for implementation of the code to fulfill requirements are being created.

This blog will explain what TDD is and show you a practical example of using TDD and clean architecture to test the most relevant parts of the app by writing unit tests in Flutter.

Test-Driven Development cycle

TDD is also called the Red-Green-Refactor process regarding its iterative process, which is consisted of 3 following steps:

  • Write a test that fails (Red)
  • Write a code to make a test pass (Green)
  • Refactor your code to obtain high code quality (Refactor)

Repeating this process for every single piece of feature results in full test code coverage.

Benefits of TDD approach

The code is easier to maintain

Developers produce cleaner, more manageable, and readable code using the TDD approach. Furthermore, less effort is required to focus on smaller and more digestible code chunks. Having clean code is helpful in situations when a project is transferred to a different member or team.

Modular design

Focus is on a single feature at a time and not moving to the next one until the test is passed. Project written in such iterations makes it easier to discover bugs and reuse the code. In addition, adherence to these design principles contributes to better solution architecture.

Easier code refactoring

Refactoring stands for optimization of the existing code, and it has one goal – to make it easier to introduce. If the code for a small feature or an improvement passes the initial tests, it can be refactored to acceptable standards. That is a mandatory part of the TDD process.

No need for a documentation

There is no need to create time-consuming and detailed documentation using the TDD approach. TDD involves many simple unit tests, and they can act as documentation. Also, these unit tests show how the code is supposed to work.

Less debugging

When the code has fewer bugs, developers spend less time fixing them. Also, it is easier to identify errors, and developers are notified sooner when something breaks. That is one of the main benefits of the TDD approach.

Unit testing

Unit testing is a type of software testing where an individual unit of code is tested under various conditions to verify its correctness. A unit may be a function, method, class, state, or just a variable.

A unit test has three phases:

  • Arrange – create the object of the unit that is being tested, prepare prerequisites, and arrange success or Failure for the case that is being tested
  • Act – call the methods, and assign the result variable with a value returned from the component being tested under the given conditions
  • Assert – verify whether the unit behaves as expected, and we may assert an outcome by checking if the result from the act part matches the one we expect and have prepared in arrange part using expect() function or expect a method to be called using a verify() function

Sometimes unit tests might depend on classes that fetch data from live web services or databases. Calling live services or databases slows down test execution, might return unexpected results, and make it difficult to test all possible scenarios. To avoid relying on such dependencies, they are being mocked out.

After mock and unit variables are created, we create instances of dependencies and the unit we are testing in a setUp() method. For example, we use a group of tests to create a group() method. Then we create variables that we need to act with or validate that the unit performs as expected.

Now that you know TDD benefits and unit testing practices, let’s get onto the practical example.

The idea behind the application

We made a Q Recipes application using a Spoonacular API to show the TDD approach in practice. The application uses Riverpod for state management, Firebase for authentication with Google, and Cloud Firebase as a database to manage users’ favorite recipes. It has the following features:

  • Authentication with Google
  • Flexitarian and Vegan recipes
  • Add/Remove a recipe from favorites
  • Filtering favorite recipes (All/ Vegan)

To showcase the TDD approach to each feature of the Q Recipes application, we will split the process into the following sections:

State management

Firstly we define states based on feature requirements. And then start with the implementation of state notifiers following TDD principles. In test implementation, we create our notifier, call the method, and ensure that the state is as expected.

UI

To leave the focus on TDD and unit testing the most relevant parts of the app, the blog presents only a visual representation of UI with a short description of core widgets and logic used to build the UI. Parts that contain logic like providers or extensions are presented in this section by following TDD principles.

Repositories

We begin with defining an interface to determine boundaries between layers. That allows us to mock dependencies easily without implementing them right away. For example, repository methods return type is Either<Failure, Model> as repository catches the exceptions and returns them as Failure when they occur and Model is a class returned when a response is valid. And then onto the implementation following TDD principles.

Data Sources

Repositories use Data Sources to get the actual data. In this section, the interface is also defined first. After that, errors are handled by throwing exceptions which will be converted to the Either type by repository as explained previously. Since our logic here is only throwing exceptions and returning a valid model depending on the outcome of the code that depends on 3rd party APIs that we are using, we skip testing this section and implement it right away.

List of app dependencies in pubspec.yaml:

name: q_recipes_tdd
description: A new Flutter project.
version: 1.0.0+1
environment:
  sdk: ">=2.16.1 <3.0.0"
dependencies:
  auto_route: ^5.0.4
  cloud_firestore: ^4.3.1
  connectivity_plus: ^3.0.2
  cupertino_icons: ^1.0.5
  dartz: ^0.10.1
  dio: ^4.0.6
  firebase_auth: ^4.2.5
  firebase_core: ^2.4.1
  flutter:
    sdk: flutter
  flutter_flavorizr: ^2.1.5
  flutter_html: ^3.0.0-alpha.6
  font_awesome_flutter: ^10.3.0
  freezed_annotation: ^2.2.0
  get_it: ^7.2.0
  google_fonts: ^3.0.1
  google_sign_in: ^5.4.3
  hooks_riverpod: ^2.1.3
  injectable: 1.5.3
  json_annotation: ^4.7.0
  json_serializable: ^6.5.4
  pretty_dio_logger: ^1.1.1
  retrofit: ^3.3.1
  shared_preferences: ^2.0.16
  url_launcher: ^6.1.7
  anim_search_bar: ^2.0.3
  flash: ^2.0.5
  custom_radio_grouped_button: ^2.1.2+2
  firebase_auth_mocks: ^0.10.3
dev_dependencies:
  flutter_test:
    sdk: flutter
  freezed: null
  build_runner: 2.3.0
  lint: ^2.0.1
  auto_route_generator: ^5.0.2
  flutter_launcher_icons: ^0.11.0
  flutter_lints: ^2.0.1
  injectable_generator: null
  mockito: ^5.3.2
  state_notifier_test: ^0.0.9
  retrofit_generator: null

For testing purposes, we use mockito package, which is a great shortcut for creating mocks, and the state_notifier_test library, which makes it easy to test StateNotifier. Testing library flutter_test uses a test package that provides the core functionality for writing tests in Dart as a foundation and exposes constructs by which projects may configure their tests, including initialization constructs like setUp() and setUpAll() methods.

Authentication with Google

Firebase Authentication is used as a backend service to authenticate users in an app. In this app, we used a third-party provider such as Google.

Initially, you need to get Firebase set up, and you can find instructions for doing that here. For Google Sign-In, most configuration is already set up. However, for use with Android, you need to include your SHA-1 key to the Firebase console (you can find instructions for finding your SHA-1 in Android’s official docs).

Requirements:

  • Show the sign-in page when the user is not logged in
  • Sign in user
  • Create a new user if it doesn’t exist
  • Show loading widget when the user is being authenticated
  • Show loading widget when the user is being authenticated
  • Redirect to home page with recipes when the user is logged in
  • Sign out user
  • Show error message when user sign-in/out fails

State management

States:

  • Initial
  • Authenticating
  • Authenticated
  • Unauthenticated
  • Saved user
  • Signed out
  • Failure

Auth notifier tests:

import 'package:dartz/dartz.dart';
import 'package:flutter_tdd_q/common/data/current_user_provider.dart';
import 'package:flutter_tdd_q/common/data/repositories/user_repository.dart';
import 'package:flutter_tdd_q/common/domain/models/auth/auth_success.dart';
import 'package:flutter_tdd_q/common/domain/models/failure.dart';
import 'package:flutter_tdd_q/common/domain/models/user.dart';
import 'package:flutter_tdd_q/features/auth/data/repositories/auth_repository.dart';
import 'package:flutter_tdd_q/features/auth/presentation/state/auth_notifier.dart';
import 'package:flutter_tdd_q/features/auth/presentation/state/auth_state.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:state_notifier_test/state_notifier_test.dart';
import 'auth_notifier_test.mocks.dart';
// Mock dependencies
@GenerateMocks([UserProvider, AuthRepository, UserRepository])
void main() {
  // Prepare prerequisites
  late UserProvider _userProvider;
  late AuthRepository _authRepo;
  late UserRepository _userRepository;
  setUp(() {
    _userProvider = MockUserProvider();
    _authRepo = MockAuthRepository();
    _userRepository = MockUserRepository();
  });
  AuthSuccess getAuthSuccessWithCompletedRegistration() {
    return const AuthSuccess(
      registrationComplete: true,
      user: User(id: '1', email: 'aaa@gmail.com'),
    );
  }
  AuthSuccess getAuthSuccessWithUncompletedRegistration() {
    return const AuthSuccess(
      registrationComplete: false,
      user: User(id: '1', email: 'aaa@gmail.com'),
    );
  }
  User getMockedUser() {
    return const User(id: '1', email: 'aaa@gmail.com');
  }
  stateNotifierTest<AuthNotifier, AuthState>(
    "Emits [] when no methods are called",
    // Arrange - create notifier
    build: () => AuthNotifier(_userProvider, _authRepo, _userRepository),
    // Act - call the methods
    actions: (_) {},
    // Assert
    expect: () => [],
  );
// Group tests by AuthNotifier methods
  group('sign in tests', () {
    stateNotifierTest<AuthNotifier, AuthState>(
      'Emits [AuthState.authenticated] when user is already registered and has successfully logged in',
      // Arrange - create notifier
      build: () => AuthNotifier(_userProvider, _authRepo, _userRepository),
      // Arrange - set up dependencies
      setUp: () async {
        when(_authRepo.signIn()).thenAnswer(
          (_) async => Future.value(
            right(getAuthSuccessWithCompletedRegistration()),
          ),
        );
      },
      // Act - call the methods
      actions: (stateNotifier) => stateNotifier.signIn(),
      // Assert
      expect: () => [
        const AuthState.authenticated(),
      ],
    );
    stateNotifierTest<AuthNotifier, AuthState>(
      'Emits [AuthState.failure] when user has not successfully logged in',
      build: () => AuthNotifier(_userProvider, _authRepo, _userRepository),
      setUp: () async {
        when(_authRepo.signIn()).thenAnswer(
          (_) async => Future.value(
            left(
              const Failure.authenticationFailure(
                  AuthFailureReason.googleSignIn),
            ),
          ),
        );
      },
      actions: (stateNotifier) async => stateNotifier.signIn(),
      expect: () => [
        const AuthState.failure(
      Failure.authenticationFailure(AuthFailureReason.googleSignIn)),
      ],
    );
    stateNotifierTest<AuthNotifier, AuthState>(
      'Emits [AuthState.savedUser,AuthState.authenticated] when user is not registered and has successfully logged in and successfully saved user in the firebase',
      build: () => AuthNotifier(_userProvider, _authRepo, _userRepository),
      setUp: () async {
        when(_authRepo.signIn()).thenAnswer(
          (_) async => Future.value(
            right(getAuthSuccessWithUncompletedRegistration()),
          ),
        );
        when(_userRepository.createUser(user: getMockedUser())).thenAnswer(
          (_) async => Future.value(
            right(
              getMockedUser(),
            ),
          ),
        );
      },
      actions: (stateNotifier) async => stateNotifier.signIn(),
      expect: () => [
        const AuthState.savedUser(),
        const AuthState.authenticated(),
      ],
    );
    stateNotifierTest<AuthNotifier, AuthState>(
      'Emits [AuthState.savedUser,AuthState.authenticated] when user is not registered and has successfully logged in and successfully saved user in the firebase',
      build: () => AuthNotifier(_userProvider, _authRepo, _userRepository),
      setUp: () async {
        when(_authRepo.signIn()).thenAnswer(
          (_) async => Future.value(
            right(getAuthSuccessWithUncompletedRegistration()),
          ),
        );
        when(_userRepository.createUser(user: getMockedUser())).thenAnswer(
          (_) async => Future.value(
            left(
              const Failure.authenticationFailure(AuthFailureReason.other),
            ),
          ),
        );
      },
      actions: (stateNotifier) async => stateNotifier.signIn(),
      expect: () => [
        const AuthState.failure(
            Failure.authenticationFailure(AuthFailureReason.other)),
        const AuthState.authenticated(),
      ],
    );
  });
  group('sign out tests', () {
    stateNotifierTest<AuthNotifier, AuthState>(
      'Emits [AuthState.unauthenticated] when user signed out successfully',
      build: () => AuthNotifier(_userProvider, _authRepo, _userRepository),
      setUp: () async {
        when(_authRepo.signOut()).thenAnswer((_) => Future.value(right(unit)));
      },
      actions: (AuthNotifier stateNotifier) async {
        await stateNotifier.signOut();
      },
      expect: () => [
        const AuthState.signedOut(),
      ],
    );
    stateNotifierTest<AuthNotifier, AuthState>(
      'Emits [AuthState.failure(Failure.signOutError())] when user signed out failed',
      build: () => AuthNotifier(_userProvider, _authRepo, _userRepository),
      setUp: () async {
        when(_authRepo.signOut()).thenAnswer(
            (_) => Future.value(const Left(Failure.signOutError())));
      },
      actions: (AuthNotifier stateNotifier) async {
        await stateNotifier.signOut();
      },
      expect: () => [
        const AuthState.failure(Failure.signOutError()),
      ],
    );
  });
  group('check if authenticated tests', () {
    stateNotifierTest<AuthNotifier, AuthState>(
      'Emits [AuthState.loading,AuthState.authenticated] when registration is commpleted',
      build: () => AuthNotifier(_userProvider, _authRepo, _userRepository),
      setUp: () async {
        when(_authRepo.isRegistrationComplete())
            .thenAnswer((realInvocation) => Future.value(true));
        when(_userProvider.setup())
            .thenAnswer((realInvocation) => Future.value(true));
      },
      actions: (stateNotifier) => stateNotifier.checkIfAuthenticated(),
      expect: () => [
        const AuthState.authenticating(),
        const AuthState.authenticated(),
      ],
    );
    stateNotifierTest<AuthNotifier, AuthState>(
      'Emits [AuthState.loading,AuthState.unauthenticated] when registration is not completed',
      build: () => AuthNotifier(_userProvider, _authRepo, _userRepository),
      setUp: () async {
        when(_authRepo.isRegistrationComplete())
            .thenAnswer((realInvocation) => Future.value(false));
        when(_userProvider.setup())
            .thenAnswer((realInvocation) => Future.value(true));
      },
      actions: (stateNotifier) => stateNotifier.checkIfAuthenticated(),
      expect: () => [
        const AuthState.authenticating(),
        const AuthState.unauthenticated(),
      ],
    );
  });
}<span style="font-family: Consolas, Monaco, monospace;background-color: #ffffff;color: #333333;font-size: 16px"></span>

Auth notifier implementation:

import ‘package:flutter_tdd_q/common/data/current_user_provider.dart’;
import ‘package:flutter_tdd_q/common/data/repositories/user_repository.dart’;
import ‘package:flutter_tdd_q/common/domain/models/user.dart’;
import ‘package:flutter_tdd_q/features/auth/data/repositories/auth_repository.dart’;
import ‘package:flutter_tdd_q/features/auth/presentation/state/auth_state.dart’;
import ‘package:hooks_riverpod/hooks_riverpod.dart’;
class AuthNotifier extends StateNotifier<AuthState> {
final UserProvider _userProvider;
final AuthRepository _authRepo;
final UserRepository _userRepository;
AuthNotifier(
this._userProvider,
this._authRepo,
this._userRepository,
) : super(const AuthState.initial());
Future<void> checkIfAuthenticated() async {
state = const AuthState.authenticating();
await Future.delayed(const Duration(seconds: 2));
final regComplete = await _authRepo.isRegistrationComplete();
if (regComplete) {
await _userProvider.setup();
state = const AuthState.authenticated();
} else {
state = const AuthState.unauthenticated();
}
}
Future<void> signIn() async {
final signInResult = await _authRepo.signIn();
await signInResult.fold(
(failure) async => state = AuthState.failure(failure),
(success) async {
if (!success.registrationComplete) {
await _setNewUser(success.user);
}
state = const AuthState.authenticated();
},
);
}
Future<void> _setNewUser(User user) async {
final userResult = await _userRepository.createUser(user: user);
state = userResult.fold(
(failure) => AuthState.failure(failure),
(success) => const AuthState.savedUser(),
);
}
Future<void> signOut() async {
final result = await _authRepo.signOut();
state = result.fold(
(failure) => AuthState.failure(failure),
(success) => const AuthState.signedOut(),
);
}
}<span style=”font-family: Consolas, Monaco, monospace;background-color: #ffffff;color: #333333;font-size: 16px”></span>

UI

A core widget of the sign-in page is a Column containing widgets that display an app title, logo, and Google sign-in button which calls signIn() method on a state provider authNotifierProvider based on which state user gets redirected further if everything goes as expected or properly notified if an error occurs.

Repositories

The authentication repository handles data from AuthRemoteDataSource and AuthLocalDataSource to enable signing users in and out.

Authentication repository interface:

abstract class IAuthRepository {
  Future<Either<Failure, AuthSuccess>> signIn();
  Future signOut();
  Future<bool> isRegistrationComplete();
}

Authentication repository tests:

import ‘package:dartz/dartz.dart’;
import ‘package:flutter_tdd_q/common/domain/data_source_exception.dart’;
import ‘package:flutter_tdd_q/common/domain/models/auth/auth_exception.dart’;
import ‘package:flutter_tdd_q/common/domain/models/auth/auth_success.dart’;
import ‘package:flutter_tdd_q/common/domain/models/failure.dart’;
import ‘package:flutter_tdd_q/common/domain/models/user.dart’;
import ‘package:flutter_tdd_q/common/domain/models/user_credentials.dart’;
import ‘package:flutter_tdd_q/features/auth/data/datasources/auth_local_data_source.dart’;
import ‘package:flutter_tdd_q/features/auth/data/datasources/auth_remote_data_source.dart’;
import ‘package:flutter_tdd_q/features/auth/data/repositories/auth_repository.dart’;
import ‘package:flutter_test/flutter_test.dart’;
import ‘package:mockito/annotations.dart’;
import ‘package:mockito/mockito.dart’;
import ‘auth_repository_test.mocks.dart’;

// Mock dependencies
@GenerateMocks([AuthRemoteDataSource, AuthLocalDataSource])
void main() {
// Prepare prerequisites
late AuthRemoteDataSource mockAuthRemoteDataSource;
late AuthLocalDataSource mockAuthLocalDataSource;
late AuthRepository authRepository;
setUp(() {
mockAuthRemoteDataSource = MockAuthRemoteDataSource();
mockAuthLocalDataSource = MockAuthLocalDataSource();
authRepository =
AuthRepository(mockAuthRemoteDataSource, mockAuthLocalDataSource);
});
const userCredentials = UserCredentials(email: ‘test@t.com’, uid: ‘3445uidX’);
final user = User(id: userCredentials.uid, email: userCredentials.email);
final authSuccess = AuthSuccess(registrationComplete: true, user: user);
const authFailureOther =
Failure.authenticationFailure(AuthFailureReason.other);
const authFailureGoogleSignIn =
Failure.authenticationFailure(AuthFailureReason.googleSignIn);
const authenticationLocalDataSourceFailure =
Failure.authenticationLocalDataSourceFailure();
// Group tests by methods from AuthRepository
group(‘Sign in tests’, () {
void setupSuccess() {
when(mockAuthRemoteDataSource.googleSignIn())
.thenAnswer((_) async => userCredentials);
when(mockAuthLocalDataSource.storeUserCredentials(userCredentials))
.thenAnswer((_) async {});
when(mockAuthRemoteDataSource.isRegistrationComplete())
.thenAnswer((_) async => Future.value(true));
}
test(
‘authRepository.signIn should call authRemoteDataSource.googleSignIn’,
() async {
// Arrange
setupSuccess();
// Act
await authRepository.signIn();
// Assert
verify(mockAuthRemoteDataSource.googleSignIn());
},
);
test(
‘authRepository.signIn should call authRemoteDataSource.isRegistrationComplete’,
() async {
setupSuccess();
await authRepository.signIn();
verify(mockAuthRemoteDataSource.isRegistrationComplete());
},
);
test(
‘authRepository.signIn should call authLocalDataSource.storeUserCredentials with userCredentials’,
() async {
setupSuccess();
await authRepository.signIn();
verify(mockAuthLocalDataSource.storeUserCredentials(userCredentials));
},
);
test(
‘authRepository.signIn should return authSuccess when user signed in successfully’,
() async {
setupSuccess();
final result = await authRepository.signIn();
expect(result, Right(authSuccess));
},
);
test(
‘authRepository.signIn should return AuthenticationFailure.other when sign in failed’,
() async {
when(mockAuthRemoteDataSource.googleSignIn()).thenAnswer((_) async =>
throw const AuthException(failureReason: AuthFailureReason.other));
final result = await authRepository.signIn();
expect(result, const Left(authFailureOther));
},
);
test(
‘authRepository.signIn should return AuthenticationFailure.googleSignIn when failed to get authTokens’,
() async {
when(mockAuthRemoteDataSource.googleSignIn()).thenAnswer((_) async =>
throw const AuthException(
failureReason: AuthFailureReason.googleSignIn));
final result = await authRepository.signIn();
expect(result, const Left(authFailureGoogleSignIn));
},
);
test(
‘authRepository.signIn should return AuthenticationLocalDataSourceFailure when failed to store user credentials’,
() async {
when(mockAuthRemoteDataSource.googleSignIn())
.thenAnswer((_) async => userCredentials);
when(mockAuthLocalDataSource.storeUserCredentials(userCredentials))
.thenAnswer((_) async => throw AuthLocalDataSourceException());
final result = await authRepository.signIn();
expect(result, const Left(authenticationLocalDataSourceFailure));
},
);
});
group(‘Sign out tests’, () {
void setupSuccess() {
when(mockAuthRemoteDataSource.googleSignOut()).thenAnswer((_) async {});
}
void setupError() {
when(mockAuthRemoteDataSource.googleSignOut())
.thenAnswer((_) async => throw Exception());
}
test(
‘authRepository.signOut should call authRemoteDataSource.googleSignOut’,
() async {
setupSuccess();
await authRepository.signOut();
verify(mockAuthRemoteDataSource.googleSignOut());
},
);
test(
‘authRepository.signOut should return unit when user signed out successfully’,
() async {
setupSuccess();
final result = await authRepository.signOut();
expect(result, const Right(unit));
},
);
test(
‘authRepository.signOut should return signOutError when sign out failed’,
() async {
setupError();
final result = await authRepository.signOut();
expect(result, const Left(Failure.signOutError()));
},
);
});
group(‘Is registration complete tests’, () {
void setupSuccess() {
when(mockAuthRemoteDataSource.isRegistrationComplete())
.thenAnswer((_) async => Future.value(true));
}
void setupError() {
when(mockAuthRemoteDataSource.isRegistrationComplete())
.thenAnswer((_) async => throw Exception());
}
test(
‘authRepository.isRegistrationComplete should call authRemoteDataSource.isRegistrationComplete’,
() async {
setupSuccess();
await authRepository.isRegistrationComplete();
verify(mockAuthRemoteDataSource.isRegistrationComplete());
},
);
test(
‘authRepository.isRegistrationComplete should return true when registration completed successfully’,
() async {
setupSuccess();
final result = await authRepository.isRegistrationComplete();
expect(result, const Right(true));
},
);
test(
‘authRepository.isRegistrationComplete should return false when registration failed’,
() async {
setupError();
final result = await authRepository.isRegistrationComplete();
expect(result, const Left(false));
},
);
});
}<span style=”font-family: Consolas, Monaco, monospace;background-color: #ffffff;color: #333333;font-size: 16px”></span>

Authentication repository implementation:

import ‘package:dartz/dartz.dart’;
import ‘package:flutter_tdd_q/common/domain/data_source_exception.dart’;
import ‘package:flutter_tdd_q/common/domain/models/auth/auth_exception.dart’;
import ‘package:flutter_tdd_q/common/domain/models/auth/auth_success.dart’;
import ‘package:flutter_tdd_q/common/domain/models/failure.dart’;
import ‘package:flutter_tdd_q/common/domain/models/user.dart’;
import ‘package:flutter_tdd_q/features/auth/data/datasources/auth_local_data_source.dart’;
import ‘package:flutter_tdd_q/features/auth/data/datasources/auth_remote_data_source.dart’;
class AuthRepository implements IAuthRepository {
final AuthRemoteDataSource _authRemoteDataSource;
final AuthLocalDataSource _authLocalDataSource;
AuthRepository(this._authRemoteDataSource, this._authLocalDataSource);
@override
Future<Either<Failure, AuthSuccess>> signIn() async {
try {
final userCredentials = await _authRemoteDataSource.googleSignIn();
await _authLocalDataSource.storeUserCredentials(userCredentials);
final isRegistrationComplete =
await _authRemoteDataSource.isRegistrationComplete();
final user = User(id: userCredentials.uid, email: userCredentials.email);
return Right(AuthSuccess(
registrationComplete: isRegistrationComplete, user: user));
} on AuthException catch (e) {
return Left(Failure.authenticationFailure(e.failureReason));
} on AuthLocalDataSourceException {
return const Left(Failure.authenticationLocalDataSourceFailure());
}
}
@override
Future<Either<Failure, Unit>> signOut() async {
try {
await _authRemoteDataSource.googleSignOut();
return const Right(unit);
} catch (e) {
return const Left(Failure.signOutError());
}
}
@override
Future<bool> isRegistrationComplete() async {
return _authRemoteDataSource.isRegistrationComplete();
}
}

Data Sources

AuthRemoteDataSource is used for Google authentication with Firebase. We use the google_sign_in package to manually carry out the sign-in flow and pass the resulting ID token to Firebase.

There are four main steps that we follow working with Google sign-in SDK:

Retrieve the user’s Google account information

final GoogleSignInAccount? googleUser = await GoogleSignIn().signIn();

Authenticate the user’s account through Google to retrieve a GoogleSignInAuthentication object that contains their ID token, access token, and other relevant information.

final googleAuthTokens = await googleUser?.authentication;

Create a new GoogleAuthCredential object using the user’s access and ID tokens.

return GoogleAuthProvider.credential(
      accessToken: googleAuthTokens.accessToken,
      idToken: googleAuthTokens.idToken,
    );

Authenticate in Firebase with the user’s credentials.

final firebaseCredential = await _firebaseAuth.signInWithCredential(credential);

Full authentication process:

import ‘package:cloud_firestore/cloud_firestore.dart’;
import ‘package:firebase_auth/firebase_auth.dart’;
import ‘package:flutter_tdd_q/common/auth/domain/auth_exception.dart’;
import ‘package:flutter_tdd_q/common/auth/domain/user_credentials.dart’;
import ‘package:flutter_tdd_q/common/data/current_user_provider.dart’;
import ‘package:flutter_tdd_q/common/data/firebase_collections.dart’;
import ‘package:flutter_tdd_q/common/domain/models/failure.dart’;
import ‘package:google_sign_in/google_sign_in.dart’;
abstract class AuthRemoteDataSource {
Future<UserCredentials> googleSignIn();
Future googleSignOut();
Future<bool> isRegistrationComplete();
}
class FirebaseAuthDatasource implements AuthRemoteDataSource {
final FirebaseAuth _firebaseAuth;
final FirebaseFirestore _fireStore;
final UserProvider _userProvider;
FirebaseAuthDatasource(
this._firebaseAuth, this._fireStore, this._userProvider);
Future<UserCredentials> _authenticateWith(
{required AuthCredential credential}) async {
try {
final firebaseCredential =
await _firebaseAuth.signInWithCredential(credential);
return firebaseCredential.toUserCredentials;
} on FirebaseAuthException catch (e) {
throw AuthException(failureReason: _mapExceptionCodeToMessage(e.code));
}
}
AuthFailureReason _mapExceptionCodeToMessage(String code) {
switch (code) {
default:
return AuthFailureReason.other;
}
}
Future<AuthCredential?> _getAuthCredentials() async {
final GoogleSignInAccount? googleUser = await GoogleSignIn().signIn();
final googleAuthTokens = await googleUser?.authentication;
if (googleAuthTokens == null)
throw const AuthException(failureReason: AuthFailureReason.googleSignIn);
return GoogleAuthProvider.credential(
accessToken: googleAuthTokens.accessToken,
idToken: googleAuthTokens.idToken,
);
}
@override
Future<UserCredentials> googleSignIn() async {
final credentials = await _getAuthCredentials();
if (credentials != null)
return _authenticateWith(credential: credentials);
else
throw const AuthException(failureReason: AuthFailureReason.other);
}
Future<void> userSetUp() async => _userProvider.setup();
@override
Future<bool> isRegistrationComplete() async {
try {
final uid = _firebaseAuth.currentUser?.uid ?? ‘user’;
final userDoc = await _fireStore.doc(‘/${Collections.users}/$uid’).get();
return userDoc.data() != null;
} on Exception {
return false;
}
}
@override
Future googleSignOut() async {
await FirebaseAuth.instance.signOut();
}
}
extension _FirebaseUserCredentialsMappable on UserCredential {
UserCredentials get toUserCredentials =>
UserCredentials(email: user!.email!, uid: user!.uid);
}

AuthLocalDataSource is used for the local storage of user credentials

import ‘package:cloud_firestore/cloud_firestore.dart’;
import ‘package:firebase_auth/firebase_auth.dart’;
import ‘package:flutter_tdd_q/common/data/current_user_provider.dart’;
import ‘package:flutter_tdd_q/common/data/firebase_collections.dart’;
import ‘package:flutter_tdd_q/common/domain/models/auth/auth_exception.dart’;
import ‘package:flutter_tdd_q/common/domain/models/failure.dart’;
import ‘package:flutter_tdd_q/common/domain/models/user_credentials.dart’;
import ‘package:google_sign_in/google_sign_in.dart’;
abstract class AuthRemoteDataSource {
Future<UserCredentials> googleSignIn();
Future googleSignOut();
Future<bool> isRegistrationComplete();
}
class FirebaseAuthDatasource implements AuthRemoteDataSource {
final FirebaseAuth _firebaseAuth;
final FirebaseFirestore _fireStore;
final UserProvider _userProvider;
FirebaseAuthDatasource(
this._firebaseAuth, this._fireStore, this._userProvider);
Future<UserCredentials> _authenticateWith(
{required AuthCredential credential}) async {
try {
final firebaseCredential =
await _firebaseAuth.signInWithCredential(credential);
return firebaseCredential.toUserCredentials;
} on FirebaseAuthException catch (e) {
throw AuthException(failureReason: _mapExceptionCodeToMessage(e.code));
}
}
AuthFailureReason _mapExceptionCodeToMessage(String code) {
switch (code) {
default:
return AuthFailureReason.other;
}
}
Future<AuthCredential?> _getAuthCredentials() async {
final GoogleSignInAccount? googleUser = await GoogleSignIn().signIn();
final googleAuthTokens = await googleUser?.authentication;
if (googleAuthTokens == null)
throw const AuthException(failureReason: AuthFailureReason.googleSignIn);
return GoogleAuthProvider.credential(
accessToken: googleAuthTokens.accessToken,
idToken: googleAuthTokens.idToken,
);
}
@override
Future<UserCredentials> googleSignIn() async {
final credentials = await _getAuthCredentials();
if (credentials != null)
return _authenticateWith(credential: credentials);
else
throw const AuthException(failureReason: AuthFailureReason.other);
}
Future<void> userSetUp() async => _userProvider.setup();
@override
Future<bool> isRegistrationComplete() async {
try {
final uid = _firebaseAuth.currentUser?.uid ?? ‘user’;
final userDoc = await _fireStore.doc(‘/${Collections.users}/$uid’).get();
return userDoc.data() != null;
} on Exception {
return false;
}
}
@override
Future googleSignOut() async {
await FirebaseAuth.instance.signOut();
}
}
extension _FirebaseUserCredentialsMappable on UserCredential {
UserCredentials get toUserCredentials =>
UserCredentials(email: user!.email!, uid: user!.uid);
}<span style=”font-family: Consolas, Monaco, monospace;background-color: #ffffff;color: #333333;font-size: 16px”></span>

Flexitarian and Vegan recipes

Requirements:

  • Show loading widget when the repository is waiting for the response from the server, or it is processing data
  • Show List of recipes when data fetching is successfully done
  • Show error message when data fetching fails
  • Show recipe details on the recipe card click

State management

We have two kinds of recipes and a separate screen for each, so we have a state notifier for each one.

  • Initial
  • Loading
  • Loaded
  • Error

Flexitarian recipes state notifier tests:

import 'package:dartz/dartz.dart';
import 'package:flutter_tdd_q/common/data/repositories/recipe_repository.dart';
import 'package:flutter_tdd_q/common/domain/models/failure.dart';
import 'package:flutter_tdd_q/common/domain/models/recipe.dart';
import 'package:flutter_tdd_q/features/flexiterian/presentation/pages/state/provider/flexi_recipes_notifier.dart';
import 'package:flutter_tdd_q/features/flexiterian/presentation/pages/state/provider/flexi_recipes_state.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:state_notifier_test/state_notifier_test.dart';
import 'flexi_recipes_notifier_test.mocks.dart';
// Mock dependencies
@GenerateMocks([RecipeRepository])
void main() {
  // Prepare prerequisites
  late RecipeRepository mockRecipeRepository;
  setUp(() {
    mockRecipeRepository = MockRecipeRepository();
  });
  const data = Recipes.data(recipes: [Recipe()]);
  const recipes = [Recipe()];
  stateNotifierTest<FlexiRecipesNotifier, FlexiRecipesState>(
    'Emits [] when no methods are called',
    // Arrange - create notifier
    build: () => FlexiRecipesNotifier(mockRecipeRepository),
    // Act - call the methods
    actions: (_) {},
    // Assert
    expect: () => [],
  );
  // Group tests by FlexiRecipesNotifier methods
  group('recipes load tests', () {
    stateNotifierTest<FlexiRecipesNotifier, FlexiRecipesState>(
      'Emits [FlexiRecipesState.loading, FlexiRecipesState.loaded] when data is fetched successfully',
      // Arrange - create notifier
      build: () => FlexiRecipesNotifier(mockRecipeRepository),
      // Arrange - set up dependencies
      setUp: () async {
        when(mockRecipeRepository.getRecipes()).thenAnswer(
          (invocation) async {
            return Future<Either<Failure, Recipes>>(
              () => Future.value(right(data)),
            );
          },
        );
      },
      // Act - call the methods
      actions: (FlexiRecipesNotifier stateNotifier) async {
        await stateNotifier.loadRecipes();
      },
      // Assert
      expect: () => [
        const FlexiRecipesState.loading(),
        const FlexiRecipesState.loaded(recipes: recipes),
      ],
    );
    stateNotifierTest<FlexiRecipesNotifier, FlexiRecipesState>(
      'Emits [FlexiRecipesState.loading, FlexiRecipesState.error] when fetching data fails',
      build: () => FlexiRecipesNotifier(mockRecipeRepository),
      setUp: () async {
        when(mockRecipeRepository.getRecipes()).thenAnswer(
          (invocation) async {
            return Future<Either<Failure, Recipes>>(
              () => Future.value(left(const Failure.unexpectedDataError())),
            );
          },
        );
      },
      actions: (FlexiRecipesNotifier stateNotifier) async {
        await stateNotifier.loadRecipes();
      },
      expect: () => [
        const FlexiRecipesState.loading(),
        FlexiRecipesState.error(
            error: const Failure.unexpectedDataError().failureMessage()),
      ],
    );
  });
}

Vegan recipes state notifier tests:

import ‘package:dartz/dartz.dart’;
import ‘package:flutter_tdd_q/common/data/repositories/recipe_repository.dart’;
import ‘package:flutter_tdd_q/common/domain/models/failure.dart’;
import ‘package:flutter_tdd_q/common/domain/models/recipe.dart’;
import ‘package:flutter_tdd_q/features/vegan/presentation/state/provider/vegan_recipes_notifier.dart’;
import ‘package:flutter_tdd_q/features/vegan/presentation/state/provider/vegan_recipes_state.dart’;
import ‘package:flutter_test/flutter_test.dart’;
import ‘package:mockito/annotations.dart’;
import ‘package:mockito/mockito.dart’;
import ‘package:state_notifier_test/state_notifier_test.dart’;
import ‘vegan_recipes_notifier_test.mocks.dart’;
// Mock dependencies
@GenerateMocks([RecipeRepository])
void main() {
// Prepare prerequisites
late RecipeRepository mockRecipeRepository;
setUp(() {
mockRecipeRepository = MockRecipeRepository();
});
const data = Recipes.data(recipes: [Recipe()]);
const recipes = [Recipe()];
stateNotifierTest<VeganRecipesNotifier, VeganRecipesState>(
‘Emits [] when no methods are called’,
// Arrange – create notifier
build: () => VeganRecipesNotifier(mockRecipeRepository),
// Act – call the methods
actions: (_) {},
// Assert
expect: () => [],
);
// Group tests by VeganRecipesNotifier methods
group(‘vegan recipes load tests’, () {
stateNotifierTest<VeganRecipesNotifier, VeganRecipesState>(
‘Emits [VeganRecipesState.loading, VeganRecipesState.loaded] when data is fetched successfully’,
// Arrange – create notifier
build: () => VeganRecipesNotifier(mockRecipeRepository),
// Arrange – set up dependencies
setUp: () async {
when(mockRecipeRepository.getRecipes(tags: [‘vegan’])).thenAnswer(
(invocation) async {
return Future<Either<Failure, Recipes>>(
() => Future.value(right(data)),
);
},
);
},
// Act – call the methods
actions: (VeganRecipesNotifier stateNotifier) async {
await stateNotifier.loadVeganRecipes();
},
// Assert
expect: () => [
const VeganRecipesState.loading(),
const VeganRecipesState.loaded(recipes: recipes),
],
);
stateNotifierTest<VeganRecipesNotifier, VeganRecipesState>(
‘Emits [VeganRecipesState.loading, VeganRecipesState.error] when data is fetched unsuccessfully’,
build: () => VeganRecipesNotifier(mockRecipeRepository),
setUp: () async {
when(mockRecipeRepository.getRecipes(tags: [‘vegan’])).thenAnswer(
(invocation) async {
return Future<Either<Failure, Recipes>>(
() => Future.value(left(const Failure.unexpectedDataError())),
);
},
);
},
actions: (VeganRecipesNotifier stateNotifier) async {
await stateNotifier.loadVeganRecipes();
},
expect: () => [
const VeganRecipesState.loading(),
VeganRecipesState.error(
error: const Failure.unexpectedDataError().failureMessage()),
],
);
});
}<span style=”font-family: Consolas, Monaco, monospace;background-color: #ffffff;color: #333333;font-size: 16px”></span>

Flexitarian recipes state notifier implementation:

import ‘package:flutter_tdd_q/common/data/repositories/recipe_repository.dart’;
import ‘package:flutter_tdd_q/common/domain/models/failure.dart’;
import ‘package:hooks_riverpod/hooks_riverpod.dart’;
import ‘flexi_recipes_state.dart’;
class FlexiRecipesNotifier extends StateNotifier<FlexiRecipesState> {
final RecipeRepository _recipeRepository;
FlexiRecipesNotifier(this._recipeRepository)
: super(const FlexiRecipesState.initial());
Future<void> loadRecipes() async {
state = const FlexiRecipesState.loading();
final result = await _recipeRepository.getRecipes();
state = result.fold(
(failure) => FlexiRecipesState.error(error: failure.failureMessage()),
(data) => FlexiRecipesState.loaded(recipes: data.recipes ?? []));
}
}<span style=”font-family: Consolas, Monaco, monospace;background-color: #ffffff;color: #333333;font-size: 16px”></span>

Vegan recipes state notifier implementation:

import ‘package:flutter_tdd_q/common/data/repositories/recipe_repository.dart’;
import ‘package:flutter_tdd_q/common/domain/models/failure.dart’;
import ‘package:hooks_riverpod/hooks_riverpod.dart’;
import ‘vegan_recipes_state.dart’;
class VeganRecipesNotifier extends StateNotifier<VeganRecipesState> {
final RecipeRepository _recipeRepository;
VeganRecipesNotifier(this._recipeRepository)
: super(const VeganRecipesState.initial());
Future<void> loadVeganRecipes() async {
state = const VeganRecipesState.loading();
final result = await _recipeRepository.getRecipes(tags: [‘vegan’]);
state = result.fold(
(failure) => VeganRecipesState.error(error: failure.failureMessage()),
(data) => VeganRecipesState.loaded(recipes: data.recipes ?? []));
}
}

UI

Vegan and Flexitarian pages display content based on vegan and flexitarian recipes state notifier’s state. In addition, a listView with recipe cards is displayed if data is loaded successfully. In the case of the error state, an error message is displayed, and in the case of the loading state, CircularLoader is displayed.

Click on the recipe card displays recipe details page containing information about a recipe displayed in a CustomScrollView with a SliverAppBar and SliverList.

Repositories

The recipes repository’s job is to get recipes data from the ApiClient, which contains code for talking to the Spoonacular API. We are fetching both vegan and flexitarian recipes from the same endpoint, so we have only one method getRecipes() in our IRecipeRepository interface.

Recipe repository interface:

abstract class IRecipeRepository {
  Future<Either<Failure, Recipes>> getRecipes(
      {int? number, List<String>? tags});
}

Recipe repository tests:

import ‘package:dartz/dartz.dart’;
import ‘package:dio/dio.dart’;
import ‘package:flutter_tdd_q/common/data/repositories/recipe_repository.dart’;
import ‘package:flutter_tdd_q/common/domain/models/failure.dart’;
import ‘package:flutter_tdd_q/common/domain/models/recipe.dart’;
import ‘package:flutter_tdd_q/common/network/api_client.dart’;
import ‘package:flutter_test/flutter_test.dart’;
import ‘package:mockito/annotations.dart’;
import ‘package:mockito/mockito.dart’;
import ‘recipe_repository_test.mocks.dart’;
// Mock dependencies
@GenerateMocks([ApiClient])
void main() {
// Prepare prerequisites
late MockApiClient apiClient;
late RecipeRepository repository;
final tags = [‘vegan’];
setUp(() {
apiClient = MockApiClient();
repository = RecipeRepository(apiClient);
});
const recipes = Recipes.data(recipes: [
Recipe(
vegan: true,
summary: ‘Test recipe ready in 5 minutes.’,
title: ‘Test recipe’,
readyInMinutes: 5),
Recipe(
vegan: true,
summary: ‘Test recipe ready in 5 minutes.’,
title: ‘Test recipe’,
readyInMinutes: 5),
]);
void setupSuccess() {
when(apiClient.getRandomRecipes()).thenAnswer((_) async => recipes);
when(apiClient.getRandomRecipes(tags: tags))
.thenAnswer((_) async => recipes);
}
void setupError() {
when(apiClient.getRandomRecipes())
.thenThrow(DioError(requestOptions: RequestOptions(path: ”)));
}
// Group tests by methods from RecipeRepository
group(‘get recipes tests’, () {
test(
‘should call _apiClient.getRandomRecipes’,
() async {
// Arrange
setupSuccess();
// Act
await repository.getRecipes();
// Assert
verify(apiClient.getRandomRecipes());
},
);
test(
‘should call _apiClient.getRandomRecipes with tag vegan’,
() async {
setupSuccess();

await repository.getRecipes(tags: tags);
verify(apiClient.getRandomRecipes(tags: tags));
},
);
test(
‘should return list of recipes when api client successfully retrieves data’,
() async {
setupSuccess();
final result = await repository.getRecipes();
final expected = right(recipes);
expect(result, expected);
},
);
});
test(
‘should return failure when api client unsuccessfully retrieves data’,
() async {
setupError();
final result = await repository.getRecipes();
final expected = left(const Failure.offline());
expect(result, expected);
},
);
}<span style=”font-family: Consolas, Monaco, monospace;background-color: #ffffff;color: #333333;font-size: 16px”></span>

Recipe Repository implementation:

import ‘package:dartz/dartz.dart’;
import ‘package:dio/dio.dart’;
import ‘package:flutter_tdd_q/common/domain/models/failure.dart’;
import ‘package:flutter_tdd_q/common/domain/models/recipe.dart’;
import ‘package:flutter_tdd_q/common/network/api_client.dart’;
class RecipeRepository implements IRecipeRepository {
final ApiClient _api;
RecipeRepository(
this._api,
);
@override
Future<Either<Failure, Recipes>> getRecipes(
{int? number, List<String>? tags}) async {
try {
final response = await _api.getRandomRecipes(tags: tags);
return right(response);
} on DioError catch (e) {
return left(e.handleFailure());
}
}
}

Data Sources

To fetch flexitarian recipes data, we use Get Random Recipes endpoint from Spoonacular API. To get the vegan recipes, we are using the same endpoint and including the ‘tags’ parameter with the List containing ‘vegan’ value in the request. To create our API request, we will use retrofit generator.

API Client:

<span class=”hljs-keyword”>import</span> <span class=”hljs-string”>’package:dio/dio.dart'</span>;
<span class=”hljs-keyword”>import</span> <span class=”hljs-string”>’package:flutter_tdd_q/common/domain/models/recipe.dart'</span>;
<span class=”hljs-keyword”>import</span> <span class=”hljs-string”>’package:retrofit/retrofit.dart'</span>;
<span class=”hljs-keyword”>part</span> <span class=”hljs-string”>’api_client.g.dart'</span>;
<span class=”hljs-meta”>@RestApi</span>()
<span class=”hljs-keyword”>abstract</span> <span class=”hljs-class”><span class=”hljs-keyword”>class</span> <span class=”hljs-title”>ApiClient</span> </span>{
<span class=”hljs-keyword”>factory</span> ApiClient.createDefault(Dio dio) = _ApiClient;
<span class=”hljs-meta”>@GET</span>(<span class=”hljs-string”>’/recipes/random'</span>)
Future<Recipes> getRandomRecipes({
<span class=”hljs-meta”>@Query</span>(<span class=”hljs-string”>’number'</span>) <span class=”hljs-built_in”>int</span> number = <span class=”hljs-number”>10</span>,
<span class=”hljs-meta”>@Query</span>(<span class=”hljs-string”>’tags'</span>) <span class=”hljs-built_in”>List</span><<span class=”hljs-built_in”>String</span>>? tags,
});
}

Favorite recipes

Requirements:

  • Save a recipe as a favorite
  • Remove a recipe from favorites
  • Show all recipes on the favorite recipes screen
  • Enable filtering of recipes (vegan/ all)
  • Show loading indicator when fetching recipes
  • Show message when the user has no favorite recipes
  • Show error message when add/remove or get recipes fails

State management

Favorite list notifier states:

  • Initial
  • Loading
  • Loaded
  • Empty
  • Error

Favorite notifier states:

  • Initial
  • Submitting
  • Data
  • Error

Favorite state notifier tests:

import 'package:dartz/dartz.dart';
import 'package:flutter_tdd_q/common/domain/models/failure.dart';
import 'package:flutter_tdd_q/common/domain/models/recipe.dart';
import 'package:flutter_tdd_q/features/favorite/data/repositories/favorite_repository.dart';
import 'package:flutter_tdd_q/features/favorite/presentation/state/favorite_notifier.dart';
import 'package:flutter_tdd_q/features/favorite/presentation/state/favorite_state.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:state_notifier_test/state_notifier_test.dart';
import 'favorite_notifier_test.mocks.dart';
// Mock dependencies
@GenerateMocks([FavoriteRepository])
void main() {
  // Prepare prerequisites
  late FavoriteRepository mockFavoriteRepository;
  setUp(() {
    mockFavoriteRepository = MockFavoriteRepository();
  });
  const recipe = Recipe(
      vegan: false,
      summary: 'Test recipe ready in 5 minutes.',
      title: 'Test recipe',
      readyInMinutes: 5);
  stateNotifierTest<FavoriteNotifier, FavoriteState>(
      'Emits [] when no methods are called',
      // Arrange - create notifier
      build: () => FavoriteNotifier(mockFavoriteRepository),
      // Act - call the methods
      actions: (FavoriteNotifier stateNotifier) {},
      // Assert
      expect: () => []);
  // Group tests by FavoriteNotifier methods
  group('add favorite recipe tests', () {
    stateNotifierTest<FavoriteNotifier, FavoriteState>(
      'Emits [submitting, data] when recipe is added to favorites successfully',
      // Arrange - create notifier
      build: () => FavoriteNotifier(mockFavoriteRepository),
      // Arrange - set up dependencies
      setUp: () async {
        when(mockFavoriteRepository.addToFavorites(recipe: recipe)).thenAnswer(
          (invocation) async {
            return Future<Either<Failure, Unit>>(
              () => Future.value(right(unit)),
            );
          },
        );
      },
      // Act - call the methods
      actions: (FavoriteNotifier stateNotifier) async {
        await stateNotifier.addToFavorites(recipe: recipe);
      },
      // Assert
      expect: () => [
        const FavoriteState.submitting(),
        const FavoriteState.data(favorite: true, recipe: recipe),
      ],
    );
    stateNotifierTest<FavoriteNotifier, FavoriteState>(
      'Emits [submitting, error] when adding a recipe to favorites fails',
      build: () => FavoriteNotifier(mockFavoriteRepository),
      setUp: () async {
        when(mockFavoriteRepository.addToFavorites(recipe: recipe)).thenAnswer(
          (invocation) async {
            return Future<Either<Failure, Unit>>(
              () => Future.value(left(const Failure.serverError())),
            );
          },
        );
      },
      actions: (FavoriteNotifier stateNotifier) async {
        await stateNotifier.addToFavorites(recipe: recipe);
      },
      expect: () => [
        const FavoriteState.submitting(),
        const FavoriteState.error(error: Failure.serverError()),
      ],
    );
  });
  group('remove favourite recipe tests', () {
    stateNotifierTest<FavoriteNotifier, FavoriteState>(
      'Emits [submitting, data] when recipe is removed from favorites successfully',
      build: () => FavoriteNotifier(mockFavoriteRepository),
      setUp: () async {
        when(mockFavoriteRepository.removeFromFavorites(recipe: recipe))
            .thenAnswer(
          (invocation) async {
            return Future<Either<Failure, Unit>>(
              () => Future.value(right(unit)),
            );
          },
        );
      },
      actions: (FavoriteNotifier stateNotifier) async {
        await stateNotifier.removeFromFavorites(recipe: recipe);
      },
      expect: () => [
        const FavoriteState.submitting(),
        const FavoriteState.data(favorite: false, recipe: recipe),
      ],
    );
    stateNotifierTest<FavoriteNotifier, FavoriteState>(
      'Emits [submitting, error] when removing a recipe from favorites failed',
      build: () => FavoriteNotifier(mockFavoriteRepository),
      setUp: () async {
        when(mockFavoriteRepository.removeFromFavorites(recipe: recipe))
            .thenAnswer(
          (invocation) async {
            return Future<Either<Failure, Unit>>(
              () => Future.value(left(const Failure.serverError())),
            );
          },
        );
      },
      actions: (FavoriteNotifier stateNotifier) async {
        await stateNotifier.removeFromFavorites(recipe: recipe);
      },
      expect: () => [
        const FavoriteState.submitting(),
        const FavoriteState.error(error: Failure.serverError()),
      ],
    );
  });
}
<span style="font-family: Consolas, Monaco, monospace;background-color: #ffffff;color: #333333;font-size: 16px"></span>

Favorite list state notifier tests:

import 'dart:async';
import 'package:dartz/dartz.dart';
import 'package:flutter_tdd_q/common/domain/models/failure.dart';
import 'package:flutter_tdd_q/common/domain/models/recipe.dart';
import 'package:flutter_tdd_q/features/favorite/data/repositories/favorite_repository.dart';
import 'package:flutter_tdd_q/features/favorite/presentation/state/favorite_list_notifier.dart';
import 'package:flutter_tdd_q/features/favorite/presentation/state/favorite_list_state.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:state_notifier_test/state_notifier_test.dart';
import 'favorite_list_notifier_test.mocks.dart';
// Mock dependencies
@GenerateMocks([FavoriteRepository])
void main() {
  // Prepare prerequisites
  late FavoriteRepository mockFavoriteRepository;
  setUp(() {
    mockFavoriteRepository = MockFavoriteRepository();
  });
  const recipe = Recipe(
      vegan: false,
      summary: 'Test recipe ready in 5 minutes.',
      title: 'Test recipe',
      readyInMinutes: 5);
  const recipeVegan = Recipe(
      vegan: true,
      summary: 'Test recipe ready in 5 minutes.',
      title: 'Test recipe',
      readyInMinutes: 5);
  const recipes = [recipe, recipeVegan];
  final successListOfRecipesList = <Either<Failure, List<Recipe>>>[
    right(recipes),
  ];
  final recipesStream = Stream<Either<Failure, List<Recipe>>>.fromIterable(
      successListOfRecipesList);
  stateNotifierTest<FavoriteListNotifier, FavoriteListState>(
      'Emits [] when no methods are called',
      // Arrange - create notifier
      build: () => FavoriteListNotifier(mockFavoriteRepository),
      // Act - call the methods
      actions: (_) {},
      // Assert
      expect: () => []);
  // Group tests by FavoriteListNotifier methods
  group('get favorite recipes tests', () {
    stateNotifierTest<FavoriteListNotifier, FavoriteListState>(
      'Emits [loading, loaded] when recipe is fetched from favorites successfully',
      // Arrange - create notifier
      build: () => FavoriteListNotifier(mockFavoriteRepository),
      // Arrange - set up dependencies
      setUp: () {
        when(mockFavoriteRepository.getFavorites()).thenAnswer((_) {
          return recipesStream;
        });
      },
      // Act - call the methods
      actions: (FavoriteListNotifier stateNotifier) async {
        await stateNotifier.getFavorites();
      },
      // Assert
      expect: () => [
        const FavoriteListState.loading(),
        const FavoriteListState.loaded(recipes: [...recipes]),
      ],
    );
    stateNotifierTest<FavoriteListNotifier, FavoriteListState>(
      'Emits [loading, error] when fetching recipes from favorites fails',
      build: () => FavoriteListNotifier(mockFavoriteRepository),
      setUp: () async {
        when(mockFavoriteRepository.getFavorites()).thenAnswer((_) {
          final streamController =
              StreamController<Either<Failure, List<Recipe>>>();
          streamController.add(left(const Failure.serverError()));
          return streamController.stream;
        });
      },
      actions: (FavoriteListNotifier stateNotifier) async {
        await stateNotifier.getFavorites();
      },
      expect: () => [
        const FavoriteListState.loading(),
        const FavoriteListState.error(error: Failure.serverError()),
      ],
    );
  });
}<span style="font-family: Consolas, Monaco, monospace;background-color: #ffffff;color: #333333;font-size: 16px"></span>

Favorite state notifier implementation:

import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_tdd_q/features/favorite/data/repositories/favorite_repository.dart';
import 'package:flutter_tdd_q/features/favorite/presentation/state/favorite_list_state.dart';
class FavoriteListNotifier extends StateNotifier<FavoriteListState> {
  final FavoriteRepository _favoriteRepository;
  late StreamSubscription _streamSubscription;
  FavoriteListNotifier(
    this._favoriteRepository,
  ) : super(const FavoriteListState.initial());
  Future<void> getFavorites() async {
    state = const FavoriteListState.loading();
    _streamSubscription = _favoriteRepository.getFavorites().listen((result) {
      state = result.fold(
        (l) => FavoriteListState.error(error: l),
        (r) {
          if (r.isEmpty) return const FavoriteListState.empty(recipes: []);
          return FavoriteListState.loaded(recipes: r);
        },
      );
    });
    @override
    Future<void> close() {
      return _streamSubscription.cancel();
    }
  }
}<span style="font-family: Consolas, Monaco, monospace;background-color: #ffffff;color: #333333;font-size: 16px"></span>

UI

A favorite page displays content based on the favorite recipes list state notifier’s state. A listView with recipes saved as favorites is displayed if data is loaded successfully. In the case of the error state, an error message is displayed, and in the case of the loading state, CircularLoader is displayed.

Filtering recipes is done with our FilterRadioButton widget, which extends ConsumerWidget to enable listening to a state provider filterProvider to change the radioButtonValue by switching FilterFavorites enum values. Displaying a selected kind of recipe is then done by our ​​filteredFavoritesListProvider of type AutoDisposeProvider< List <RecipeElement>> which recipes list content it provides depends on our filterProvider and favoriteListNotifierProvider state.

Filter favorites enum extension tests:

import 'package:flutter_tdd_q/features/favorite/presentation/state/favorite_list_state.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
  test(
      "getFiltersString should return 'VEGAN' for FilterFavorites.vegan filter",
      () {
    const filter = FilterFavorites.vegan;
    final filterString = filter.getFiltersString();
    const expected = 'VEGAN';
    expect(filterString, expected);
  });
  test("getFiltersString should return 'ALL' for FilterFavorites.all filter",
      () {
    const filter = FilterFavorites.all;
    final filterString = filter.getFiltersString();
    const expected = 'ALL';
    expect(filterString, expected);
  });
}

Filter favorites enum extension implementation:

enum FilterFavorites { vegan, all }
extension FilterExtension on FilterFavorites {
  String getFiltersString() {
    switch (this) {
      case FilterFavorites.all:
        return "ALL";
      case FilterFavorites.vegan:
        return "VEGAN";
    }
  }
}

Filter provider tests:

import ‘package:flutter_tdd_q/common/providers/providers.dart’;
import ‘package:flutter_tdd_q/features/favorite/presentation/state/favorite_list_state.dart’;
import ‘package:flutter_test/flutter_test.dart’;
import ‘package:hooks_riverpod/hooks_riverpod.dart’;
void main() {
group(‘filter favorite recipes provider tests’, () {
test(‘filter provider initial state should be FilterFavorites.all’, () {
// Arrange – create container that stores the state of providers
final container = ProviderContainer();
// Assert
expect(container.read(filterProvider), FilterFavorites.all);
});
test(
‘filter provider state should be FilterFavorites.all when state overriden to all’,
() {
// Arrange – create container that stores the state of providers
// Arrange – override behaviour of filterProvider
final container = ProviderContainer(
overrides: [filterProvider.overrideWith((ref) => FilterFavorites.all)],
);
// Assert
expect(container.read(filterProvider), FilterFavorites.all);
});
test(
‘filter provider state should be FilterFavorites.vegan when state overriden to vegan’,
() {
final container = ProviderContainer(
overrides: [filterProvider.overrideWith((ref) => FilterFavorites.vegan)],
);
expect(container.read(filterProvider), FilterFavorites.vegan);
});
});
}

Filtered favorites list provider tests:

import ‘package:dartz/dartz.dart’;
import ‘package:flutter_tdd_q/common/domain/models/failure.dart’;
import ‘package:flutter_tdd_q/common/domain/models/recipe.dart’;
import ‘package:flutter_tdd_q/common/providers/providers.dart’;
import ‘package:flutter_tdd_q/features/favorite/data/repositories/favorite_repository.dart’;
import ‘package:flutter_tdd_q/features/favorite/presentation/state/favorite_list_notifier.dart’;
import ‘package:flutter_tdd_q/features/favorite/presentation/state/favorite_list_state.dart’;
import ‘package:flutter_test/flutter_test.dart’;
import ‘package:hooks_riverpod/hooks_riverpod.dart’;
import ‘package:mockito/annotations.dart’;
import ‘package:mockito/mockito.dart’;
import ‘filtered_favorites_list_provider_test.mocks.dart’;
// Mock dependencies
@GenerateMocks([FavoriteRepository])
void main() {
// Prepare prerequisites
late FavoriteRepository mockFavoriteRepository;
setUp(() {
mockFavoriteRepository = MockFavoriteRepository();
});
const recipe = Recipe(
vegan: false,
summary: ‘Test recipe ready in 5 minutes.’,
title: ‘Test recipe’,
readyInMinutes: 5);
const recipeVegan = Recipe(
vegan: true,
summary: ‘Test recipe ready in 5 minutes.’,
title: ‘Test recipe’,
readyInMinutes: 5);
const recipes = [recipe, recipeVegan];
final successListOfRecipesList = <Either<Failure, List<Recipe>>>[
right(recipes),
];
final recipesStream = Stream<Either<Failure, List<Recipe>>>.fromIterable(
successListOfRecipesList);
final filteredRecipes =
recipes.where((element) => element.vegan == true).toList();
group(‘filter favourite recipes tests’, () {
void setUpSuccess() {
when(mockFavoriteRepository.getFavorites()).thenAnswer((_) {
return recipesStream;
});
}
test(
‘filtered favorites list provider should return list with all recipes initially’,
() async {
// Arrange – create container that stores the state of providers
// Arrange – override behaviour of filterProvider and set up dependencies
setUpSuccess();
final container = ProviderContainer(
overrides: [
favoriteListNotifierProvider.overrideWith(
(ref) => FavoriteListNotifier(mockFavoriteRepository)),
],
);
// Act
await container
.read(favoriteListNotifierProvider.notifier)
.getFavorites();
// Assert
expect(container.read(filteredFavoritesListProvider), recipes);
});
test(
‘filtered favorites list provider should return list with vegan recipes after filterProviders state changes to FilterFavorites.vegan’,
() async {
setUpSuccess();
final container = ProviderContainer(
overrides: [
favoriteListNotifierProvider.overrideWith(
(ref) => FavoriteListNotifier(mockFavoriteRepository)),
filterProvider.overrideWith((ref) => FilterFavorites.vegan),
],
);
await container
.read(favoriteListNotifierProvider.notifier)
.getFavorites();
expect(container.read(filteredFavoritesListProvider), filteredRecipes);
});
test(
‘filtered favorites list provider should return list with all recipes after filterProviders state changes to FilterFavorites.all’,
() async {
setUpSuccess();
final container = ProviderContainer(
overrides: [
favoriteListNotifierProvider.overrideWith(
(ref) => FavoriteListNotifier(mockFavoriteRepository)),
filterProvider.overrideWith((ref) => FilterFavorites.all),
],
);
await container
.read(favoriteListNotifierProvider.notifier)
.getFavorites();
expect(container.read(filteredFavoritesListProvider), recipes);
});
});
}

Providers implementation:

final filterProvider =
    StateProvider<FilterFavorites>((_) => FilterFavorites.all);


final favoriteListNotifierProvider =
    StateNotifierProvider.autoDispose<FavoriteListNotifier, FavoriteListState>(
  (ref) => FavoriteListNotifier(
    ref.watch(favoriteRepositoryProvider),
  ),
);
final filteredFavoritesListProvider = Provider.autoDispose<List<Recipe>>((ref) {
  final filter = ref.watch(filterProvider);
  final favoriteList = ref
      .watch(favoriteListNotifierProvider)
      .maybeMap(orElse: () => <Recipe>[], loaded: (state) => state.recipes);
  switch (filter) {
    case FilterFavorites.all:
      return favoriteList;
    case FilterFavorites.vegan:
      return favoriteList.where((element) => element.vegan == true).toList();
  }
});

Adding and removing a recipe from favorites is done with our HeartButton widget that calls removeFromFavorites() or addToFavorites() methods on a favoriteNotifierProvider. The user is informed about the outcome according to favoriteNotifierProvider’s state.

Repositories

Favorite repository handles FavoriteRemoteDataSource responses to enable adding, removing, and viewing favorite recipes.

Favorite repository tests:

import 'dart:async';
import 'package:dartz/dartz.dart';
import 'package:flutter_tdd_q/common/domain/data_source_exception.dart';
import 'package:flutter_tdd_q/common/domain/models/failure.dart';
import 'package:flutter_tdd_q/common/domain/models/recipe.dart';
import 'package:flutter_tdd_q/features/favorite/data/datasources/favorite_remote_data_source.dart';
import 'package:flutter_tdd_q/features/favorite/data/repositories/favorite_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'favorite_repository_test.mocks.dart';
// Mock dependencies
@GenerateMocks([FavoriteRemoteDataSource])
void main() {
  // Prepare prerequisites
  late FavoriteRemoteDataSource mockFavoriteRemoteDataSource;
  late FavoriteRepository favoriteRepository;
  setUp(() {
    mockFavoriteRemoteDataSource = MockFavoriteRemoteDataSource();
    favoriteRepository = FavoriteRepository(mockFavoriteRemoteDataSource);
  });
  const recipe = Recipe(
      vegan: false,
      summary: 'Test recipe ready in 5 minutes.',
      title: 'Test recipe',
      readyInMinutes: 5);
  const recipeVegan = Recipe(
      vegan: true,
      summary: 'Test recipe ready in 5 minutes.',
      title: 'Test recipe',
      readyInMinutes: 5);
  const recipes = [recipe, recipeVegan];
  final listOfRecipesList = [recipes, recipes];
  final recipesStream = Stream.fromIterable(listOfRecipesList);
  // Group tests by methods from FavoriteRepository
  group('get recipes from favourites tests', () {
    void setupSuccess() {
     when(mockFavoriteRemoteDataSource.getFavorites()).thenAnswer((_) async* {
        yield* recipesStream;
      });
    }
    void setupError() {
     when(mockFavoriteRemoteDataSource.getFavorites()).thenAnswer((_) async* {
        yield* throw DataSourceException();
      });
    }
    test(
      'favoriteRepository.getFavorites should call favouriteRemoteDataSource.getFavorites',
      () async {
        // Arrange
        setupSuccess();
        // Act
        await favoriteRepository.getFavorites().forEach((_) {});
        // Assert
        verify(mockFavoriteRemoteDataSource.getFavorites());
      },
    );
    test(
        'should return streams with listOfRecipesList lists when favoriteRemoteDataSource successfully fetched recipes from favorites',
        () async {
      setupSuccess();
      int i = 0;
      await favoriteRepository.getFavorites().forEach((result) {
        expect(result, right(listOfRecipesList[i]));
        i++;
      });
    });
    test(
      'should return failure when favoriteRemoteDataSource unsuccessfully fetched recipes from favorites',
      () async {
        setupError();
        await favoriteRepository.getFavorites().forEach((result) {
          expect(result, left(const Failure.serverError()));
        });
      },
    );
  });
  group('add recipe to favorites tests', () {
    void setupSuccess() {
      when(mockFavoriteRemoteDataSource.addFavorite(recipe: recipe))
          .thenAnswer((_) async => unit);
    }
    void setupError() {
      when(mockFavoriteRemoteDataSource.addFavorite(recipe: recipe))
          .thenAnswer((_) async => throw DataSourceException());
    }
    test(
      'favoriteRepository.addToFavorites should call favoriteRemoteDataSource.addFavorite with recipe',
      () async {
        setupSuccess();
        await favoriteRepository.addToFavorites(recipe: recipe);
        verify(mockFavoriteRemoteDataSource.addFavorite(recipe: recipe));
      },
    );
    test(
      'should return right(unit) when favoriteRemoteDataSource successfully added recipe to favorites',
      () async {
        setupSuccess();
        final result = await favoriteRepository.addToFavorites(recipe: recipe);
        final expected = right(unit);
        expect(result, expected);
      },
    );
    test(
      'should return failure when favoriteRemoteDataSource unsuccessfully added recipe to favorites',
      () async {
        setupError();
        final result = await favoriteRepository.addToFavorites(recipe: recipe);
        final expected = left(const Failure.serverError());
        expect(result, expected);
      },
    );
  });
  group('remove recipe from favorites tests', () {
    void setupSuccess() {
      when(mockFavoriteRemoteDataSource.removeFavorite(recipe: recipe))
          .thenAnswer((_) async => unit);
    }
    void setupError() {
      when(mockFavoriteRemoteDataSource.removeFavorite(recipe: recipe))
          .thenAnswer((_) async => throw DataSourceException());
    }
    test(
      'favouriteRepository.removeFromFavorites should call favoriteRemoteDataSource.removeFavorite with recipe',
      () async {
        setupSuccess();
        await favoriteRepository.removeFromFavorites(recipe: recipe);
        verify(mockFavoriteRemoteDataSource.removeFavorite(recipe: recipe));
      },
    );
    test(
      'should return right(unit) when favoriteRemoteDataSource successfully removed recipe from favorites',
      () async {
        setupSuccess();
        final result =
            await favoriteRepository.removeFromFavorites(recipe: recipe);
        final expected = right(unit);
        expect(result, expected);
      },
    );
    test(
      'should return failure when favoriteRemoteDataSource unsuccessfully removed recipe from favorites',
      () async {
        setupError();
        final result =
            await favoriteRepository.removeFromFavorites(recipe: recipe);
        final expected = left(const Failure.serverError());
        expect(result, expected);
      },
    );
  });
}

Favorite repository implementation:

import 'package:dartz/dartz.dart';
import 'package:flutter_tdd_q/common/domain/data_source_exception.dart';
import 'package:flutter_tdd_q/common/domain/models/failure.dart';
import 'package:flutter_tdd_q/common/domain/models/recipe.dart';
import 'package:flutter_tdd_q/features/favorite/data/datasources/favorite_remote_data_source.dart';
class FavoriteRepository implements IFavoriteRepository {
  final FavoriteRemoteDataSource _favoriteRemoteDataSource;
  FavoriteRepository(
    this._favoriteRemoteDataSource,
  );
  @override
  Future<Either<Failure, Unit>> addToFavorites({required Recipe recipe}) async {
    try {
      final response =
          await _favoriteRemoteDataSource.addFavorite(recipe: recipe);
      return right(response);
    } on DataSourceException catch (_) {
      return left(const Failure.serverError());
    }
  }
  @override
  Future<Either<Failure, Unit>> removeFromFavorites(
      {required Recipe recipe}) async {
    try {
      final response =
          await _favoriteRemoteDataSource.removeFavorite(recipe: recipe);
      return right(response);
    } on DataSourceException catch (_) {
      return left(const Failure.serverError());
    }
  }
  @override
  Stream<Either<Failure, List<Recipe>>> getFavorites() async* {
    try {
      await for (final event in _favoriteRemoteDataSource.getFavorites()) {
        yield right(event);
      }
    } on DataSourceException catch (_) {
      yield left(const Failure.serverError());
    }
  }
}<span style="font-family: Consolas, Monaco, monospace;background-color: #ffffff;color: #333333;font-size: 16px"></span>

Data Sources

FavoriteRemoteDataSource uses Cloud Firestore database to save, remove or get favorite recipes for the user signed in. You can get more information about how to work with Cloud Firestore database here.

Favorite remote data source implementation:

import 'dart:async';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:dartz/dartz.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_tdd_q/common/data/firebase_collections.dart';
import 'package:flutter_tdd_q/common/domain/data_source_exception.dart';
import 'package:flutter_tdd_q/common/domain/models/recipe.dart';
abstract class IFavoriteRemoteDataSource {
  Future<Unit> addFavorite({required Recipe recipe});
  Future<Unit> removeFavorite({required Recipe recipe});
  Stream<List<Recipe>> getFavorites();
}
class FavoriteRemoteDataSource implements IFavoriteRemoteDataSource {
  final FirebaseAuth _firebaseAuth;
  final FirebaseFirestore _firebaseFirestore;
  FavoriteRemoteDataSource(this._firebaseAuth, this._firebaseFirestore);
  @override
  Future<Unit> addFavorite({required Recipe recipe}) async {
    try {
      final user = _firebaseAuth.currentUser;
      await _firebaseFirestore
          .collection(Collections.users)
          .doc(user!.uid)
          .collection(Collections.favourites)
          .add(recipe.toJson());
      return unit;
    } on FirebaseException catch (e) {
      throw DataSourceException(message: e.message);
    }
  }
  @override
  Stream<List<Recipe>> getFavorites() async* {
    try {
      final user = _firebaseAuth.currentUser;
      yield* FirebaseFirestore.instance
          .collection(Collections.users)
          .doc(user!.uid)
          .collection(Collections.favourites)
          .snapshots()
          .transform(
            StreamTransformer.fromHandlers(
              handleData: (json, sink) => sink.add(
                json.docs.map((e) => Recipe.fromJson(e.data())).toList(),
              ),
            ),
          );
    } on FirebaseException catch (e) {
      throw DataSourceException(message: e.message);
    }
  }
  @override
  Future<Unit> removeFavorite({required Recipe recipe}) async {
    try {
      final user = _firebaseAuth.currentUser;
      final removingBook = await FirebaseFirestore.instance
          .collection(Collections.users)
          .doc(user!.uid)
          .collection(Collections.favourites)
          .where("id", isEqualTo: recipe.id)
          .get();
      removingBook.docs.first.reference.delete();
      return unit;
    } on FirebaseException catch (e) {
      throw DataSourceException(message: e.message);
    }
  }
}

Conclusion

Using the TDD approach to build apps significantly improves project results, especially in terms of bringing cost-efficiency in the long run.

TDD is often identified as something not worth bothering because of small awareness of the benefits that tests provide and the additional benefits of TDD on top of those. Hopefully, this blog spreads awareness and helps developers to get used to practicing TDD quicker. You can check out the Github repository with the full example here.

Written in collaboration with Adrijan Omicevic.


Leave a Reply

Your email address will not be published. Required fields are marked *