BLoC testing

Hello, Amigos! This is Pavel Gershevich, Mobile Team Lead of the Amiga product development agency and co-author Flutter. A lot.. We recently translated a series of articles about unit testing for you, but one important topic was left out. Today we will get acquainted with BLoC testing using unit tests.

Testing BLoC creation

Let's say we are writing a login screen for an application using email and password and we are using the library flutter_bloc to manage the state. Then we will have the following states:

@immutable
abstract class LoginState {}

class LoginInitialState extends LoginState {}

class LoginDataState extends LoginState {
  final String? email;
  final String? password;

  ...
}

class LoginLoadingState extends LoginState {
  final String? email;
  final String? password;

  ...
}

class LoginSuccessState extends LoginState {}

class LoginErrorState extends LoginState {
  final String? email;
  final String? password;
  final String? errorToShow;

  ...
}

And our BLoC constructor will look like this:

class LoginBloc extends Bloc<LoginEvent, LoginState> {
  final LoginRepository _loginRepository;


  LoginBloc(this._loginRepository) : super(LoginInitialState()) {
    ...
  }
}

During the testing process, we will supplement it with logic and interactions with other classes.

Let's now write the first test, which will check that when a BLoC is created, it has a state LoginInitialState. But first we need to create the BLoC itself. To do this, we will prepare a Mock repository object using the library mocktailit will be created only once. But we will create BLoC itself once for each test.

LoginRepository repository;
LoginBloc bloc;


setUp(() {
  repository = MockLoginRepository();
  bloc = LoginBloc(repository);
});

Next, let's write a test. To do this, we need to get the state from the newly created BLoC and check its type using Matcher isA.

test('LoginBloc should be initialized with LoginInitialState', () {
  // act
  final state = bloc.state;


  // assert
  expect(state, isA<LoginInitialState>());
});

Now we can start testing the logic inside BLoC.

Simple Unit Tests

In order to continue testing BLoC, we need to create events and write logic for this. Let's create events for entering email and password.

@immutable
abstract class LoginEvent {}


class EditedEmail extends LoginEvent {
  final String email;
  EditedEmail(this.email);
}


class EditedPassword extends LoginEvent {
  final String password;
  EditedPassword(this.password);
}

We will also need getters for email and password in the states. We will use extensions for this.

extension LoginStateX on LoginState {
  String? get emailStr {
    if (this is LoginInitialState || this is LoginSuccessState) {
      return null;
    } else if (this is LoginDataState) {
      return (this as LoginDataState).email;
    } else if (this is LoginLoadingState) {
      return (this as LoginLoadingState).email;
    } else if (this is LoginErrorState) {
      return (this as LoginErrorState).email;
    }
    return null;
  }
}

Let's write processing of these events.

on<EditedEmail>((event, emit) {
  emit(LoginDataState(
    email: event.email, 
    password: state.passwordStr,
  ));
});


on<EditedPassword>((event, emit) {
  emit(LoginDataState(
    email: state.emailStr,
    password: event.password, 
  ));
});

Let's start testing, but the standard library won't work for this. We need a package bloc_test from the creators of flutter_bloc.

Once we have installed it, we can move on to the tests themselves.

blocTest(
  'emits [LoginDataState] after adding email',
  build: () => bloc,
  act: (_bloc) => _bloc.add(EditedEmail('example@sample.com')),
  expect: () => [
    isA<LoginDataState>(),
  ],
);

In this test, we need to pass our BLoC created earlier to build. It can also be created in the parameter itself. In fact, this is our Arrange step from the AAA test writing methodology. Next comes the place for actions – act, which corresponds to the step of the same name, and expect to check what comes in the BLoC.

Let's add a test for the event – password entry.

blocTest(
  'emits [LoginDataState] after adding password',
  build: () => bloc,
  act: (_bloc) => _bloc.add(EditedPassword('myPass123')),
  expect: () => [
    isA<LoginDataState>(),
  ],
);

Tests for a complex event

There is an event of the login itself:

class LoginButtonPressed extends LoginEvent {}

And its processing:

on<LoginButtonPressed>((event, emit) async {
  emit(LoginLoadingState(
    email: state.emailStr,
    password: state.passwordStr,
  ));
  if (state.emailStr?.isNotEmpty == false ||
    state.emailStr?.isNotEmpty == false) {
      emit(LoginErrorState(
        email: state.emailStr,
        password: state.passwordStr,
        errorToShow: 'Email or password is empty',
      ));
    return;
  }


  try {
    await _loginRepository.login(
      email: state.emailStr,
      password: state.passwordStr,
    );
    emit(LoginSuccessState());
  } catch (_) {
    emit(LoginErrorState(
      email: state.emailStr,
      password: state.passwordStr,
      errorToShow: 'Server error',
    ));
  }
});

If we look closely at the code, we will see that the following cases need to be tested:

  • When the email is empty, we get LoginErrorState с ошибкой “Email or password is empty”

  • When the password is empty, we get LoginErrorState с ошибкой “Email or password is empty”

  • When email and password are empty, we get LoginErrorState с ошибкой “Email or password is empty”

  • When everything went well, we get LoginSuccessState

  • If an error occurs somewhere in the repository, we get LoginErrorState с ошибкой “Server error”

It is also worth noting that in each of these cases an event will be added LoginLoadingState.

Let's write a test for the first case, the second and third will be similar to it.

blocTest(
  'emits [LoginErrorState] if email is null',
  build: () => bloc,
  seed: () => LoginDataState(
    email: null,
    password: 'myPass123',
  ) as LoginState,
  act: (_bloc) => _bloc.add(LoginButtonPressed()),
  expect: () => [
    isA<LoginLoadingState>(),
    isA<LoginErrorState>(),
  ],
);

Here we used another blocTest property – seed, which is needed to substitute the initial state into BLoC. Thus, there is no need to additionally call all the methods, otherwise the test would look like this:

blocTest(
  'emits [LoginErrorState] if email is null',
  build: () => bloc,
  act: (_bloc) {
    _bloc.add(EditedPassword('myPass123'));
    _bloc.add(LoginButtonPressed());
  },
  expect: () => [
    isA<LoginDataState>(),
    isA<LoginLoadingState>(),
    isA<LoginErrorState>(),
  ],
);

And we wouldn't be completely sure that all events would be processed as they should.

Next, let's check for successful login.

blocTest('emits [LoginSuccessState]',
  build: () {
    when(() => repository.login(
      email: any(named: 'email'),
      password: any(named: 'password'),
    )).thenAnswer((_) => Future.value(true));
    return bloc;
  },
  seed: () => LoginDataState(
    email: 'example@sample.com',
    password: 'myPass123',
  ) as LoginState,
  act: (_bloc) => _bloc.add(LoginButtonPressed()),
  expect: () => [
    isA<LoginLoadingState>(),
    isA<LoginSuccessState>(),
  ],
  verify: (_) {
    verify(() => repository.login(
      email: any(named: 'email'),
      password: any(named: 'password'),
    )).called(1);
  });
});

The full code can be viewed here

From the example above, you can see that in the build parameter, before returning BLoC, Stubbing is used for the login function. Then everything is as in the previous tests, except that the verify parameter has appeared. This is a function that will allow you to call verify from the mocktail library.

What else can blocTest do?

In the example above, we did not consider all the capabilities of the bloc_test library. The blocTest method has several more parameters:

  • setUp is a function that creates or recreates dependencies, but it is recommended to do this in the setUp method of flutter_test

  • tearDown is a function that is used to reset dependencies, but it is recommended to do this in the tearDown method of flutter_test

  • wait is a parameter that takes a Duration and, after calling the act function, waits for the time passed to it before starting to track states.

  • skip — a parameter that indicates how many states to skip at the beginning.
    For example, from the case where we first add a password and then click on the button, we can set skip to 1 and not check that the password has been entered.

  • errors — a function similar to expect, but for checking exceptions that were thrown during BLoC operation. For example, if null is passed to add instead of an event, or an unhandled error is encountered somewhere in the handler.

Conclusion

In this article, we looked at how we can write Unit tests to test BLoC in our Flutter apps.

Have a good code everyone!

Subscribe to our author's telegram channel Flutter.Manyto always be the first to know all the news!

Similar Posts

Leave a Reply

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