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.Много, чтобы всегда все новости узнавать первыми!
ChessMax
Текст не соответствует коду? В коде и репозиторий и блок создаются для каждого теста. При этом этот код еще и не соответствует коду в гитхабе.
Эти поля повторяются в трех классах? Может быть стоило вынести в какой-то базовый класс? Или миксин? В таком случае код геттера
emailStr
можно было бы значительно сократить. Еще момент в том, что во всех состояниях эти поля зануляемые, что странно. Блок как раз и славится тем, что позволяет безболезненно моделировать точные состояния.Конечно вкусовщина, но возможно стоило не создавать расширение, а перенести этот геттер прямо в класс `LoginState`? Кстати, если сохранить
this
в локальную переменную, то не придется писать `as LoginDataState`.Обычно для параметров и локальных переменных не используется знак подчеркивания
_
в названии, так как они и так приватные.Разве проверки одного только типа состояния достаточно? А вдруг в нем измененный эмейл не сохранится? Или сохранится не тот? Аналогично и в следующем тесте. Вдруг изменение пароля повлияло на ранее сохраненный эмейл?
Как то использование
seed
выглядит не очень уместным и даже опасным. По хорошему обычно изолируют зависимости, а в логику тестируемого класса извне не лезут. Другими словами лучше взаимодействовать с тестируемой сущностью только доступными публичными методами. Так как сделано ниже.По идее `
_bloc.add(EditedPassword('myPass123'));`
должно быть вbuild
, так как это относится к части arrange.Наверное так же стоило проверить случаи когда пароль и/или логин неправильные?
Запустил тесты из репозитория, но один не проходит:
Печалька...
Дважды на пустоту проверяется email, а пароль нет? Странно, что ваши тесты это не отловили. Возможно вам стоит посмотреть в сторону TDD?