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.Много, чтобы всегда все новости узнавать первыми! 

Комментарии (1)


  1. ChessMax
    22.08.2024 09:22
    +2

    он будет создаваться всего один раз. А вот сам BLoC мы будет создавать для каждого теста единожды.

    ```

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

    ```

    Текст не соответствует коду? В коде и репозиторий и блок создаются для каждого теста. При этом этот код еще и не соответствует коду в гитхабе.

    final String? email;

    final String? password;

    Эти поля повторяются в трех классах? Может быть стоило вынести в какой-то базовый класс? Или миксин? В таком случае код геттера emailStr можно было бы значительно сократить. Еще момент в том, что во всех состояниях эти поля зануляемые, что странно. Блок как раз и славится тем, что позволяет безболезненно моделировать точные состояния.

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

    Конечно вкусовщина, но возможно стоило не создавать расширение, а перенести этот геттер прямо в класс `LoginState`? Кстати, если сохранить this в локальную переменную, то не придется писать `as LoginDataState`.

    act: (_bloc) => _bloc.add(EditedEmail('example@sample.com')),

    Обычно для параметров и локальных переменных не используется знак подчеркивания _ в названии, так как они и так приватные.

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

    Разве проверки одного только типа состояния достаточно? А вдруг в нем измененный эмейл не сохранится? Или сохранится не тот? Аналогично и в следующем тесте. Вдруг изменение пароля повлияло на ранее сохраненный эмейл?

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

    Как то использование seed выглядит не очень уместным и даже опасным. По хорошему обычно изолируют зависимости, а в логику тестируемого класса извне не лезут. Другими словами лучше взаимодействовать с тестируемой сущностью только доступными публичными методами. Так как сделано ниже.

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

    По идее `_bloc.add(EditedPassword('myPass123'));` должно быть в build, так как это относится к части arrange.

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

    Наверное так же стоило проверить случаи когда пароль и/или логин неправильные?

    Запустил тесты из репозитория, но один не проходит:

    package:matcher                              expect
    package:mocktail/src/mocktail.dart 595:5     VerificationResult.called
    test\auth_bloc\login_bloc_test.dart 93:18    main.<fn>.<fn>
    package:bloc_test/src/bloc_test.dart 230:21  testBloc.<fn>
    ===== asynchronous gap ===========================
    dart:async                                   _Completer.completeError
    package:bloc_test/src/bloc_test.dart 257:43  _runZonedGuarded.<fn>
    ===== asynchronous gap ===========================
    dart:async                                   _CustomZone.registerBinaryCallback
    package:bloc_test/src/bloc_test.dart 254:5   _runZonedGuarded.<fn>
    dart:async                                   runZonedGuarded
    package:bloc_test/src/bloc_test.dart 253:3   _runZonedGuarded
    package:bloc_test/src/bloc_test.dart 200:11  testBloc
    package:bloc_test/src/bloc_test.dart 156:13  blocTest.<fn>
    
    Expected: <1>
      Actual: <3>
    Unexpected number of calls

    Печалька...

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

    Дважды на пустоту проверяется email, а пароль нет? Странно, что ваши тесты это не отловили. Возможно вам стоит посмотреть в сторону TDD?