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.