felangel / bloc

A predictable state management library that helps implement the BLoC design pattern
https://bloclibrary.dev
MIT License
11.8k stars 3.39k forks source link

[Question] Testing debouncing bloc #726

Closed elias8 closed 4 years ago

elias8 commented 4 years ago

I saw your GitHub search example here which uses debounce operator and I implemented mine the same way. But when I try to test the bloc I am not getting all expected states because of the delay and I tried to await the bloc for the given debounce time after the event is added.

I also logged the transition and the states are correctly generated but still, the test fails.

Thanks for providing us this awesome library.

felangel commented 4 years ago

Hi @Elias8 👋 Thanks for opening an issue!

You should probably wrap your test in tester.runAsync(() {...})

You should also use tester.pump(Duration(...)) to wait for the debounce.

Hope that helps and if you are still having trouble please provide a link to a sample app which illustrates the problem you're having 👍

felangel commented 4 years ago

Closing for now but feel free to comment with additional information/questions and I'm more than happy to continue the conversation 👍

elias8 commented 4 years ago

I am trying to test the bloc itself. I don't have the source online so, I will use your GitHub search bloc example from this link and the test I wrote for this bloc is below. I am not sure if I am missing something.

import 'package:bloc_test/bloc_test.dart';
import 'package:common_github_search/common_github_search.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';

class MockGithubRepository extends Mock implements GithubRepository {}

main() {
  GithubRepository githubRepository;
  GithubSearchBloc githubSearchBloc;

  setUp(() {
    githubRepository = MockGithubRepository();
    githubSearchBloc = GithubSearchBloc(githubRepository: githubRepository);
  });

  tearDown(() {
    githubSearchBloc?.close();
  });

  group('TextChanged', () {
    String query = 'flutter';

    SearchResult searchResult = SearchResult(
      items: [
        SearchResultItem(
          fullName: 'Fulname 1',
          htmlUrl: 'url_1',
          owner: GithubUser(
            avatarUrl: 'image_url_1',
            login: 'login'
          ),
        ),
        SearchResultItem(
          fullName: 'Fullname 2',
          htmlUrl: 'url_2',
          owner: GithubUser(
            avatarUrl: 'image_url_2',
            login: 'login'
          ),
        )
      ]
    );

    blocTest(
      'sould emit [ SearchStateEmpty, SearchStateLoading, SearchStateSuccess ] when event is added ',
      build: () {
        when(githubRepository.search(query)).thenAnswer((
          _) async => searchResult);

        return githubSearchBloc;
      },
      act: (bloc) async {
        bloc.add(TextChanged(text: query));
        await Future.delayed(Duration(milliseconds: 300)); // debounce time
      },
      expect: [
        SearchStateEmpty(),
        SearchStateLoading(),
        SearchStateSuccess(searchResult.items),
      ],
    );
  });
}
felangel commented 4 years ago

@Elias8 thanks for the clarification! I'll try to take a look later by the end of day 👍

elias8 commented 4 years ago

Okay. Thanks!

felangel commented 4 years ago

@Elias8 I took a look and am going to add an optional wait to blocTest in v3.0.0 which will address this. The resulting test should look like:

blocTest(
  'should emit [ SearchStateEmpty, SearchStateLoading, SearchStateSuccess ] when event is added ',
  build: () {
    when(githubRepository.search(query)).thenAnswer((
      _) async => searchResult);
    return githubSearchBloc;
  },
  act: (bloc) {
    bloc.add(TextChanged(text: query));
  },
  wait: const Duration(milliseconds: 300),
  expect: [
    SearchStateEmpty(),
    SearchStateLoading(),
    SearchStateSuccess(searchResult.items),
  ],
);
felangel commented 4 years ago

Merged in #742 and will be included in bloc_test v3.0.0

elias8 commented 4 years ago

Great! Thanks.

gellaz commented 4 years ago

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) of @felangel.

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.

felangel commented 4 years ago

Hi @gellaz 👋 Can you please share a link to a github repo which reproduces the issue you're facing?

gellaz commented 4 years ago

Hi @felangel! This is the link to the repository: https://github.com/gellaz/covtrack

Thank you in advance for your availability! :)