dimanche 28 juin 2020

Testing LoginBloc with bloc_test library and debounce time

I'm testing my Flutter application and in particular the BLoC responsible of the logic behind the login form. I used the same code that can be found on the flutter_bloc library documentation examples (https://bloclibrary.dev/#/flutterfirebaselogintutorial).

This is the code for the LoginState:

part of 'login_bloc.dart';

/// Here is a list of the possible [LoginState] in which the [LoginForm] can be:
/// [empty]: initial state of the [LoginForm]
/// [loading]: state of the [LoginForm] when we are validating credentials
/// [failure]: state of the [LoginForm] when a login attempt has failed
/// [success]: state of the [LoginForm] when a login attempt has succeeded
class LoginState extends Equatable {
  final bool isEmailValid;
  final bool isPasswordValid;
  final bool isSubmitting;
  final bool isSuccess;
  final bool isFailure;

  bool get isFormValid => isEmailValid && isPasswordValid;

  const LoginState({
    @required this.isEmailValid,
    @required this.isPasswordValid,
    @required this.isSubmitting,
    @required this.isSuccess,
    @required this.isFailure,
  });

  factory LoginState.empty() {
    return LoginState(
      isEmailValid: true,
      isPasswordValid: true,
      isSubmitting: false,
      isSuccess: false,
      isFailure: false,
    );
  }

  factory LoginState.loading() {
    return LoginState(
      isEmailValid: true,
      isPasswordValid: true,
      isSubmitting: true,
      isSuccess: false,
      isFailure: false,
    );
  }

  factory LoginState.failure() {
    return LoginState(
      isEmailValid: true,
      isPasswordValid: true,
      isSubmitting: false,
      isSuccess: false,
      isFailure: true,
    );
  }

  factory LoginState.success() {
    return LoginState(
      isEmailValid: true,
      isPasswordValid: true,
      isSubmitting: false,
      isSuccess: true,
      isFailure: false,
    );
  }

  LoginState update({
    bool isEmailValid,
    bool isPasswordValid,
  }) {
    return copyWith(
      isEmailValid: isEmailValid,
      isPasswordValid: isPasswordValid,
      isSubmitting: false,
      isSuccess: false,
      isFailure: false,
    );
  }

  LoginState copyWith({
    bool isEmailValid,
    bool isPasswordValid,
    bool isSubmitEnabled,
    bool isSubmitting,
    bool isSuccess,
    bool isFailure,
  }) {
    return LoginState(
      isEmailValid: isEmailValid ?? this.isEmailValid,
      isPasswordValid: isPasswordValid ?? this.isPasswordValid,
      isSubmitting: isSubmitting ?? this.isSubmitting,
      isSuccess: isSuccess ?? this.isSuccess,
      isFailure: isFailure ?? this.isFailure,
    );
  }

  @override
  List<Object> get props => [
        isEmailValid,
        isPasswordValid,
        isSubmitting,
        isSuccess,
        isFailure,
      ];

  @override
  String toString() {
    return '''
    LoginState {
      isEmailValid: $isEmailValid,
      isPasswordValid: $isPasswordValid,
      isSubmitting: $isSubmitting,
      isSuccess: $isSuccess,
      isFailure: $isFailure,
    }''';
  }
}

This is the code for the LoginEvent:

part of 'login_bloc.dart';

/// List of [LoginEvent] objects to which our [LoginBloc] will be reacting to:
/// [EmailChanged] - notifies the BLoC that the user has changed the email.
/// [PasswordChanged] - notifies the BLoC that the user has changed the password.
/// [Submitted] - notifies the BLoC that the user has submitted the form.
/// [LoginWithGooglePressed] - notifies the BLoC that the user has pressed the Google Sign In button.
/// [LoginWithCredentialsPressed] - notifies the BLoC that the user has pressed the regular sign in button.
abstract class LoginEvent extends Equatable {
  const LoginEvent();

  @override
  List<Object> get props => [];
}

class EmailChanged extends LoginEvent {
  final String email;

  const EmailChanged({@required this.email});

  @override
  List<Object> get props => [email];

  @override
  String toString() => 'EmailChanged { email :$email }';
}

class PasswordChanged extends LoginEvent {
  final String password;

  const PasswordChanged({@required this.password});

  @override
  List<Object> get props => [password];

  @override
  String toString() => 'PasswordChanged { password: $password }';
}

class Submitted extends LoginEvent {
  final String email;
  final String password;

  const Submitted({
    @required this.email,
    @required this.password,
  });

  @override
  List<Object> get props => [email, password];

  @override
  String toString() => 'Submitted { email: $email, password: $password }';
}

class LoginWithGooglePressed extends LoginEvent {}

class LoginWithCredentialsPressed extends LoginEvent {
  final String email;
  final String password;

  const LoginWithCredentialsPressed({
    @required this.email,
    @required this.password,
  });

  @override
  List<Object> get props => [email, password];

  @override
  String toString() =>
      'LoginWithCredentialsPressed { email: $email, password: $password }';
}

And this is the code for the LoginBloc:

import 'dart:async';

import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:meta/meta.dart';
import 'package:rxdart/rxdart.dart';

import '../../../utils/validators.dart';
import '../../repositories/authentication/authentication_repository.dart';

part 'login_event.dart';
part 'login_state.dart';

/// BLoC responsible for the business logic behind the login process. In particular this BLoC will
/// map the incoming [LoginEvent] to the correct [LoginState].
class LoginBloc extends Bloc<LoginEvent, LoginState> {
  /// Authentication repository that provides to the user the methods to sign-in
  /// with credentials and to sign-in with a Google account.
  final AuthenticationRepository authRepository;

  LoginBloc({@required this.authRepository}) : assert(authRepository != null);

  @override
  LoginState get initialState => LoginState.empty();

  // Overriding transformEvents in order to debounce the EmailChanged and PasswordChanged events
  // so that we give the user some time to stop typing before validating the input.
  @override
  Stream<Transition<LoginEvent, LoginState>> transformEvents(
    Stream<LoginEvent> events,
    TransitionFunction<LoginEvent, LoginState> transitionFn,
  ) {
    final nonDebounceStream = events.where((event) {
      return (event is! EmailChanged && event is! PasswordChanged);
    });
    final debounceStream = events.where((event) {
      return (event is EmailChanged || event is PasswordChanged);
    }).debounceTime(Duration(milliseconds: 300));
    return super.transformEvents(
      nonDebounceStream.mergeWith([debounceStream]),
      transitionFn,
    );
  }

  @override
  Stream<LoginState> mapEventToState(LoginEvent event) async* {
    if (event is EmailChanged) {
      yield* _mapEmailChangedToState(event.email);
    } else if (event is PasswordChanged) {
      yield* _mapPasswordChangedToState(event.password);
    } else if (event is LoginWithGooglePressed) {
      yield* _mapLoginWithGooglePressedToState();
    } else if (event is LoginWithCredentialsPressed) {
      yield* _mapLoginWithCredentialsPressedToState(
        email: event.email,
        password: event.password,
      );
    }
  }

  Stream<LoginState> _mapEmailChangedToState(String email) async* {
    yield state.update(
      isEmailValid: Validators.isValidEmail(email),
    );
  }

  Stream<LoginState> _mapPasswordChangedToState(String password) async* {
    yield state.update(
      isPasswordValid: Validators.isValidPassword(password),
    );
  }

  Stream<LoginState> _mapLoginWithGooglePressedToState() async* {
    try {
      await authRepository.signInWithGoogle();
      yield LoginState.success();
    } catch (_) {
      yield LoginState.failure();
    }
  }

  Stream<LoginState> _mapLoginWithCredentialsPressedToState({
    String email,
    String password,
  }) async* {
    yield LoginState.loading();
    try {
      await authRepository.signInWithCredentials(
        email: email,
        password: password,
      );
      yield LoginState.success();
    } catch (_) {
      yield LoginState.failure();
    }
  }
}

Now I'm trying to test this bloc using the bloc_test library, and in particular I'm testing the EmailChanged. As you can see from the LoginBloc code I added a debounce time of 300 milliseconds before mapping this event to the correct state.

For testing this event I used this code:

import 'package:covtrack/business/blocs/login/login_bloc.dart';
import 'package:covtrack/business/repositories/authentication/authentication_repository.dart';
import 'package:covtrack/utils/validators.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:bloc_test/bloc_test.dart';

class MockAuthenticationRepository extends Mock
    implements AuthenticationRepository {}

void main() {
  group('LoginBloc', () {
    AuthenticationRepository authRepository;
    LoginBloc loginBloc;
    String email;

    setUp(() {
      authRepository = MockAuthenticationRepository();
      loginBloc = LoginBloc(authRepository: authRepository);
      email = 'johndoe@mail.com';
    });

    test('throws AssertionError if AuthenticationRepository is null', () {
      expect(
        () => LoginBloc(authRepository: null),
        throwsA(isAssertionError),
      );
    });

    test('initial state is LoginState.empty()', () {
      expect(loginBloc.initialState, LoginState.empty());
    });

    group('EmailChanged', () {
      blocTest(
        'emits [LoginState] with isEmailValid true',
        build: () async => loginBloc,
        act: (bloc) async => bloc.add(EmailChanged(email: email)),
        wait: const Duration(milliseconds: 300),
        expect: [LoginState.empty().update(isEmailValid: true)],
      );
    });
  });
}

When I run the test I get this error:

✓ LoginBloc throws AssertionError if AuthenticationRepository is null
✓ LoginBloc initial state is LoginState.empty()
Expected: [
            LoginState:    LoginState {  
                isEmailValid: true,  
                isPasswordValid: true,  
                isSubmitting: false,  
                isSuccess: false,  
                isFailure: false,  
              }
          ]
  Actual: []
   Which: shorter than expected at location [0]

package:test_api                             expect
package:bloc_test/src/bloc_test.dart 143:29  blocTest.<fn>.<fn>
===== asynchronous gap ===========================
dart:async                                   _asyncThenWrapperHelper
package:bloc_test/src/bloc_test.dart         blocTest.<fn>.<fn>
dart:async                                   runZoned
package:bloc_test/src/bloc_test.dart 135:11  blocTest.<fn>

✖ LoginBloc EmailChanged emits [LoginState] with isEmailValid true

I don't understand the reason why no state at all is emitted.

Aucun commentaire:

Enregistrer un commentaire