Hola, Amigos! На связи Павел Гершевич, Mobile Team Lead агентства продуктовой разработки Amiga и соавтор Flutter. Много. Недавно мы перевели для вас серию статей про модульное тестирование, но одна важная тема осталась за бортом. Сегодня познакомимся с тестированием BLoC при помощи модульных тестов.

Тестируем создание BLoC

Допустим, мы пишем экран входа в приложение по email и паролю и используем библиотеку flutter_bloc для управления состоянием. Тогда у нас будут такие состояния:

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

  ...
}

И конструктор нашего BLoC будет выглядеть так:

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


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

В процессе тестирования, будем дополнять его логикой и взаимодействиями с другими классами.

Давайте теперь напишем первый тест, который будет проверять, что при создании BLoC, он имеет состояние LoginInitialState. Но сначала нужно создать сам BLoC. Для этого подготовим Mock-объект репозитория при помощи библиотеки mocktail, он будет создаваться всего один раз. А вот сам BLoC мы будет создавать для каждого теста единожды.

LoginRepository repository;
LoginBloc bloc;


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

Далее напишем тест. Для этого нам нужно получить состояние из только что созданного BLoC и проверить его тип при помощи Matcher isA.

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


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

Теперь мы можем приступить к тестированию логики внутри BLoC.

Простые модульные тесты

Для того, чтобы нам и дальше тестировать BLoC, необходимо создать события и написать под это логику. Сделаем события для ввода email и пароля.

@immutable
abstract class LoginEvent {}


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


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

Также у состояний нам понадобятся геттеры для email и password. Для этого воспользуемся расширениями.

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

Напишем обработку данных событий.

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


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

Приступим к тестированию, но стандартная библиотека для этого уже не подойдет. Нам нужен пакет bloc_test от создателей flutter_bloc.

Когда мы его поставили, можно перейти к самим тестам.

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

В этом тесте нужно передать в build наш BLoC, созданный ранее. Его также можно создавать и в самом параметре. На самом деле, это наш шаг Arrange из методологии написания тестов AAA. Далее идет место для действий - act, который соответствует одноименному шагу, и expect для проверки того, что придет в BLoC.

Давайте добавим тест для события - ввод пароля.

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

Тесты для сложного события

Есть событие самого входа:

class LoginButtonPressed extends LoginEvent {}

И его обработка:

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

Если мы внимательно посмотрим на код, то увидим, что нужно протестировать следующие кейсы:

  • Когда email пустой, получаем LoginErrorState с ошибкой “Email or password is empty”

  • Когда пароль пустой, получаем LoginErrorState с ошибкой “Email or password is empty”

  • Когда email и пароль пустые, получаем LoginErrorState с ошибкой “Email or password is empty”

  • Когда все прошло успешно, получаем LoginSuccessState

  • Если произошла ошибка где-то в репозитории, получаем LoginErrorState с ошибкой “Server error”

Также стоит отметить, что в каждом из этих случаев будет добавляться событие LoginLoadingState.

Давайте напишем тест для первого случая, второй и третий будут аналогичны ему.

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

Тут мы использовали еще одно свойство blocTest - seed, которое нужно для подстановки изначального состояния в BLoC. Таким образом, не требуется дополнительно вызывать все методы, иначе тест выглядел бы так:

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

И мы бы не были точно уверены, что все события обработаются как надо.

Далее проверим успешный вход.

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

Полный код можно посмотреть здесь

Из примера выше видно, что в параметре build, перед тем, как вернуть BLoC, используется Stubbing для функции login. Далее все как и в прошлых тестах, за исключением того, что появился параметр verify. Это функция, которая позволит вызывать verify из библиотеки mocktail.

Что еще умеет blocTest?

В примере выше мы рассмотрели не все возможности библиотеки bloc_test. У метода blocTest есть еще несколько параметров:

  • setUp — функция, с помощью которой создаются или пересоздаются зависимости, но рекомендуется делать это в методе setUp из flutter_test

  • tearDown — функция, с помощью которой обнуляются зависимости, но рекомендуется делать это в методе tearDown из flutter_test

  • wait — параметр, который принимает Duration и после вызова функции act ожидает переданное ему время перед тем, как начать отслеживание состояний

  • skip — параметр, который показывает, сколько нужно пропустить состояний вначале.
    Например, из кейса, где мы сначала добавляем пароль, а потом нажимаем на кнопку, можно сделать skip равным 1 и не проверять, что пароль закинулся.

  • errors — функция аналогичная expect, но для проверки исключений, которые были выброшены во время работы BLoC. Например, если в add передать null вместо события, или где-то в обработчике попалась необработанная ошибка.

Заключение

В этой статье мы рассмотрели, как можно написать Unit-тесты, чтобы протестировать BLoC в наших Flutter-приложениях.

Всем хорошего кода!

Подписывайтесь на наш авторский телеграм-канал Flutter.Много, чтобы всегда все новости узнавать первыми!