June 20, 2022 - 36 min

TDD in Flutter With Example Application Using Riverpod and Firebase


				
				

Marta Rep

Flutter Developer

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)


Slika-zaslona-2022-05-24-u-13.06.35.png

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: ^3.2.4
  cloud_firestore: ^3.1.10
  connectivity_plus: ^2.2.1
  cupertino_icons: ^1.0.2
  dartz: ^0.10.1
  dio: ^4.0.6
  firebase_auth: ^3.3.11
  firebase_core: ^1.13.1
  flutter:
    sdk: flutter
  flutter_flavorizr: ^2.1.2
  flutter_html: ^2.2.1
  flutter_riverpod: ^1.0.3
  font_awesome_flutter: ^10.1.0
  freezed_annotation: ^1.1.0
  get_it: ^7.2.0
  google_fonts: ^2.3.1
  google_sign_in: ^5.2.4
  hooks_riverpod: ^1.0.3
  injectable: ^1.5.3
  json_annotation: ^4.4.0
  json_serializable: ^6.1.5
  pretty_dio_logger: ^1.1.1
  retrofit: ^3.0.1+1
  riverpod: ^1.0.3
  shared_preferences: ^2.0.13
  url_launcher: ^6.0.20
  anim_search_bar: ^2.0.2
  flash: ^2.0.3+2
  custom_radio_grouped_button: ^2.1.2
  firebase_auth_mocks: ^0.8.4


dev_dependencies:
  flutter_test:
    sdk: flutter
  freezed: null
  build_runner: ^2.1.8
  lint: ^1.8.2
  auto_route_generator: ^3.2.3


  flutter_launcher_icons: ^0.9.2
  flutter_lints: ^1.0.0
  injectable_generator: null
  mockito: ^5.1.0
  state_notifier_test: ^0.0.5
  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(),
      ],
    );
  });
}



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(),
    );
  }
}



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);


  // 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(mockAuthLocalDataSource.storeUserCredentials(userCredentials))
            .thenAnswer((_) async => throw AuthLocalDataSourceException());
        final result = await authRepository.signIn();
        expect(result, const Left(authFailureGoogleSignIn));
      },
    );
  });


  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));
      },
    );
  });
}



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);
}




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()),
      ],
    );
  });
}



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 ?? []));
  }
}



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.


Slika-zaslona-2022-05-24-u-13.04.31.png

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


Slika-zaslona-2022-05-24-u-13.04.39.png

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);
    },
  );
}



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:


import 'package:dio/dio.dart';
import 'package:flutter_tdd_q/common/domain/models/recipe.dart';
import 'package:retrofit/retrofit.dart';

part 'api_client.g.dart';

@RestApi()
abstract class ApiClient {
factory ApiClient.createDefault(Dio dio) = _ApiClient;

@GET('/recipes/random')
Future<Recipes> getRandomRecipes({
@Query('number') int number = 10,
@Query('tags') List<String>? 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()),
      ],
    );
  });
}



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()),
      ],
    );
  });
}



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();
    }
  }
}



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.


Slika-zaslona-2022-05-24-u-13.04.51.png

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
              .overrideWithProvider(StateProvider((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.overrideWithProvider(
              StateProvider((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.overrideWithProvider(
              AutoDisposeStateNotifierProvider(
                  (ref) => FavoriteListNotifier(mockFavoriteRepository))),
        ],
      );
      // Act
      yield mockFavoriteRepository.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.overrideWithProvider(
              AutoDisposeStateNotifierProvider(
                  (ref) => FavoriteListNotifier(mockFavoriteRepository))),
          filterProvider.overrideWithProvider(
              StateProvider((ref) => FilterFavorites.vegan)),
        ],
      );
      yield mockFavoriteRepository.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.overrideWithProvider(
              AutoDisposeStateNotifierProvider(
                  (ref) => FavoriteListNotifier(mockFavoriteRepository))),
          filterProvider.overrideWithProvider(
              StateProvider((ref) => FilterFavorites.all)),
        ],
      );
      yield mockFavoriteRepository.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.


Slika-zaslona-2022-05-24-u-13.05.11.png

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
        yield* favoriteRepository.getFavorites();
        // 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());
    }
  }
}



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.


Give Kudos by sharing the post!

Share:

ABOUT AUTHOR

Marta Rep

Flutter Developer

Marta is working as a Flutter developer at Q agency. When she is not expanding her Flutter knowledge, she is playing some sport or on some adventure.