Flutter Arquitectura Limpia [12] – Implementación de Bloc 2/2
Flutter Arquitectura Limpia [12] – Implementación de Bloc 2/2
Esta publicación es solo una parte de una serie de tutoriales. ¡Todas las demás partes las puede ver aquí y aprenda a diseñar sus aplicaciones Flutter!
Ya implementamos una parte importante de la lógica que se ejecuta cada vez que el evento GetTriviaForConcreteNumber llega a la conversión de entrada Bloc. Sin embargo, después de tener el int convertido y validado con éxito para el que el usuario quiere ver algunas curiosidades numéricas sorprendentes, actualmente solo lanzamos una excepción no implementada. ¡Cambiemos eso!
[wp_ad_camp_2]
Probando con un caso de uso
Lo primero que debe suceder es que el caso de uso GetConcreteNumberTrivia se ejecute con un argumento de número adecuado. Por ejemplo, si numberString del evento es "42", el entero pasado al caso de uso debería ser, por supuesto, 42.
Tenga en cuenta que ahora tenemos que hacer mock del caso de uso InputConverter y GetConcreteNumberTrivia en la parte de organización de la prueba.
number_trivia_bloc_test.dart
test(
'should get data from the concrete use case',
() async {
// arrange
when(mockInputConverter.stringToUnsignedInteger(any))
.thenReturn(Right(tNumberParsed));
when(mockGetConcreteNumberTrivia(any))
.thenAnswer((_) async => Right(tNumberTrivia));
// act
bloc.dispatch(GetTriviaForConcreteNumber(tNumberString));
await untilCalled(mockGetConcreteNumberTrivia(any));
// assert
verify(mockGetConcreteNumberTrivia(Params(number: tNumberParsed)));
},
);
Nuevamente implementaremos el código suficiente para que la prueba pase. Podemos ignorar con seguridad todas las advertencias en el IDE por ahora diciendo que el método no devuelve un Stream.
number_trivia_bloc.dart
@override
Stream<NumberTriviaState> mapEventToState(
NumberTriviaEvent event,
) async* {
if (event is GetTriviaForConcreteNumber) {
final inputEither =
inputConverter.stringToUnsignedInteger(event.numberString);
yield* inputEither.fold(
(failure) async* {
yield Error(message: INVALID_INPUT_FAILURE_MESSAGE);
},
(integer) {getConcreteNumberTrivia(Params(number: integer));},
);
}
}
Refabricando las Pruebas
[wp_ad_camp_3]
Haciendo mocking al InputConverter
para retornar un valor satisfactorio,
when(mockInputConverter.stringToUnsignedInteger(any))
.thenReturn(Right(tNumberParsed));
ocurrirá en casi todas las pruebas. Es una buena idea ponerlo en su propio método setUpMockInputConverterSuccess y usarlo en todas las pruebas, incluida la de la parte anterior.
number_trivia_bloc_test.dart
group('GetTriviaForConcreteNumber', () {
...
void setUpMockInputConverterSuccess() =>
when(mockInputConverter.stringToUnsignedInteger(any))
.thenReturn(Right(tNumberParsed));
// Use in tests below
...
}
Ejecución exitosa
Por supuesto, simplemente ejecutar el caso de uso no es suficiente. Necesitamos que la UI sepa lo que está sucediendo para que pueda mostrarle algo al usuario. ¿Qué estados debe emitir el Bloc cuando el caso de uso se ejecuta sin problemas?
Bueno, antes de llamar al caso de uso, es una buena idea mostrar algunos comentarios visuales al usuario. Si bien mostrar un Indicador de progreso circular es una tarea para la UI, debemos notificar a la UI para que lo muestre emitiendo un estado de carga.
Después de que el caso de uso devuelva el lado derecho de Cualquiera de los dos (lo que significa éxito), el Bloc debe pasar el NumberTrivia obtenido a la UI dentro de un estado Cargado.
[wp_ad_camp_4]
test.dart
test(
'should emit [Loading, Loaded] when data is gotten successfully',
() async {
// arrange
setUpMockInputConverterSuccess();
when(mockGetConcreteNumberTrivia(any))
.thenAnswer((_) async => Right(tNumberTrivia));
// assert later
final expected = [
Empty(),
Loading(),
Loaded(trivia: tNumberTrivia),
];
expectLater(bloc.state, emitsInOrder(expected));
// act
bloc.dispatch(GetTriviaForConcreteNumber(tNumberString));
},
);
La implementación comenzará a ponerse fea con todo el anidamiento, pero, por supuesto, la refactorizaremos después de terminar de escribir las pruebas.
implementation.dart
@override
Stream<NumberTriviaState> mapEventToState(
NumberTriviaEvent event,
) async* {
if (event is GetTriviaForConcreteNumber) {
final inputEither =
inputConverter.stringToUnsignedInteger(event.numberString);
yield* inputEither.fold(
(failure) async* {
yield Error(message: INVALID_INPUT_FAILURE_MESSAGE);
},
(integer) async* {
yield Loading();
final failureOrTrivia = await getConcreteNumberTrivia(
Params(number: integer),
);
yield failureOrTrivia.fold(
(failure) => throw UnimplementedError(),
(trivia) => Loaded(trivia: trivia),
);
},
);
}
}
Ejecución fallida
El tipo Either nos hace manejar el caso Failure, pero a partir de ahora, simplemente estamos lanzando un UnimplementedError cuando esto sucede. Creemos una prueba para el caso cuando el caso de uso (y, en consecuencia, el repositorio y así sucesivamente en la cadena) devuelva un error de servidor.
test.dart
test(
'should emit [Loading, Error] when getting data fails',
() async {
// arrange
setUpMockInputConverterSuccess();
when(mockGetConcreteNumberTrivia(any))
.thenAnswer((_) async => Left(ServerFailure()));
// assert later
final expected = [
Empty(),
Loading(),
Error(message: SERVER_FAILURE_MESSAGE),
];
expectLater(bloc.state, emitsInOrder(expected));
// act
bloc.dispatch(GetTriviaForConcreteNumber(tNumberString));
},
);
implementation.dart
(integer) async* {
yield Loading();
final failureOrTrivia = await getConcreteNumberTrivia(
Params(number: integer),
);
yield failureOrTrivia.fold(
(failure) => Error(message: SERVER_FAILURE_MESSAGE),
(trivia) => Loaded(trivia: trivia),
);
},
Simple, ¿no es así? Sin embargo, ServerFailure no es el único tipo de falla devuelta por el caso de uso. También se puede producir un CacheFailure y tenemos que manejarlo.
Es muy importante manejar todos los subtipos posibles de Falla dentro de un Bloc. Después de todo, queremos mostrar un mensaje de error significativo al usuario y eso es posible solo emitiendo estados de error con un mensaje personalizado.
[wp_ad_camp_5]
La prueba de CacheFailure será muy similar a la anterior, pero lo suficientemente diferente como para que la refactorización no sea realmente necesaria.
test.dart
test(
'should emit [Loading, Error] with a proper message for the error when getting data fails',
() async {
// arrange
setUpMockInputConverterSuccess();
when(mockGetConcreteNumberTrivia(any))
.thenAnswer((_) async => Left(CacheFailure()));
// assert later
final expected = [
Empty(),
Loading(),
Error(message: CACHE_FAILURE_MESSAGE),
];
expectLater(bloc.state, emitsInOrder(expected));
// act
bloc.dispatch(GetTriviaForConcreteNumber(tNumberString));
},
);
Estamos a punto de llegar a un código de aspecto realmente desagradable. Sin embargo, no se preocupe, ya que lo vamos a refactorizar en un momento.
implementation.dart
(integer) async* {
yield Loading();
final failureOrTrivia = await getConcreteNumberTrivia(
Params(number: integer),
);
yield failureOrTrivia.fold(
(failure) => Error(
message: failure is ServerFailure
? SERVER_FAILURE_MESSAGE
: CACHE_FAILURE_MESSAGE,
),
(trivia) => Loaded(trivia: trivia),
);
},
Refactorizando el Operador Ternario
La forma en que decidimos el mensaje del estado de Error deja mucho que desear. El operador ternario es en realidad una opción insegura: ¿qué pasa si hay algún otro subtipo de falla del que no tenemos conocimiento? Quiero decir, no debería suceder con Clean Architecture, pero aún así ... No importa el tipo real de la falla, a menos que sea una falla de servidor, ahora siempre tendrá asignada una CACHE_FAILURE_MESSAGE.
Creemos un método auxiliar _mapFailureToMessage separado en el que vamos a tratar de manejar los mensajes de manera más adecuada. Todavía estamos dentro de los límites de Vanilla Dart, que no admite clases selladas fuera de la caja, por lo que todavía tenemos que manejar el tipo de Failure inesperado (por si acaso).
Todo el método mapEventToState ahora se ve así:
implementation.dart
@override
Stream<NumberTriviaState> mapEventToState(
NumberTriviaEvent event,
) async* {
if (event is GetTriviaForConcreteNumber) {
final inputEither =
inputConverter.stringToUnsignedInteger(event.numberString);
yield* inputEither.fold(
(failure) async* {
yield Error(message: INVALID_INPUT_FAILURE_MESSAGE);
},
(integer) async* {
yield Loading();
final failureOrTrivia = await getConcreteNumberTrivia(
Params(number: integer),
);
yield failureOrTrivia.fold(
(failure) => Error(message: _mapFailureToMessage(failure)),
(trivia) => Loaded(trivia: trivia),
);
},
);
}
}
String _mapFailureToMessage(Failure failure) {
// Instead of a regular 'if (failure is ServerFailure)...'
switch (failure.runtimeType) {
case ServerFailure:
return SERVER_FAILURE_MESSAGE;
case CacheFailure:
return CACHE_FAILURE_MESSAGE;
default:
return 'Unexpected Error';
}
}
Obteniendo el Random Trivia
Como ya estamos acostumbrados de las partes anteriores, vamos a romper intencionalmente los principios de TDD para no volvernos locos con la implementación tediosa de básicamente el mismo código por segunda vez. A diferencia de las fuentes de datos o el repositorio, tanto el código de prueba como el código de producción para GetTriviaForRandomNumber serán más simples. ¡Esto se debe a que no usa InputValidator! Después de todo, no se necesita ningún número ingresado por el usuario para obtener curiosidades de números aleatorios.
Comenzando con las pruebas, vamos a copiar y pegar el código del grupo concreto, eliminar todo lo relacionado con el InputConverter y también cambiar cualquier referencia al caso o evento de uso concreto para hacer referencia al caso o evento de uso aleatorio.
test.dart
group('GetTriviaForRandomNumber', () {
final tNumberTrivia = NumberTrivia(number: 1, text: 'test trivia');
test(
'should get data from the random use case',
() async {
// arrange
when(mockGetRandomNumberTrivia(any))
.thenAnswer((_) async => Right(tNumberTrivia));
// act
bloc.dispatch(GetTriviaForRandomNumber());
await untilCalled(mockGetRandomNumberTrivia(any));
// assert
verify(mockGetRandomNumberTrivia(NoParams()));
},
);
test(
'should emit [Loading, Loaded] when data is gotten successfully',
() async {
// arrange
when(mockGetRandomNumberTrivia(any))
.thenAnswer((_) async => Right(tNumberTrivia));
// assert later
final expected = [
Empty(),
Loading(),
Loaded(trivia: tNumberTrivia),
];
expectLater(bloc.state, emitsInOrder(expected));
// act
bloc.dispatch(GetTriviaForRandomNumber());
},
);
test(
'should emit [Loading, Error] when getting data fails',
() async {
// arrange
when(mockGetRandomNumberTrivia(any))
.thenAnswer((_) async => Left(ServerFailure()));
// assert later
final expected = [
Empty(),
Loading(),
Error(message: SERVER_FAILURE_MESSAGE),
];
expectLater(bloc.state, emitsInOrder(expected));
// act
bloc.dispatch(GetTriviaForRandomNumber());
},
);
test(
'should emit [Loading, Error] with a proper message for the error when getting data fails',
() async {
// arrange
when(mockGetRandomNumberTrivia(any))
.thenAnswer((_) async => Left(CacheFailure()));
// assert later
final expected = [
Empty(),
Loading(),
Error(message: CACHE_FAILURE_MESSAGE),
];
expectLater(bloc.state, emitsInOrder(expected));
// act
bloc.dispatch(GetTriviaForRandomNumber());
},
);
});
Con todas estas pruebas fallando por razones obvias, vamos a copiar la implementación para cuando GetTriviaForRandomNumber llegue al Bloc desde la contraparte concreta. Por supuesto, no manejamos ninguna conversión de entrada aquí, por lo que vamos a copiar solo el código que trata con el entero ya convertido.
Solo hay dos cambios para hacer en este código copiado: llame al caso de uso GetRandomNumberTrivia y pase NoParams () ya que el caso de uso, bueno ..., no acepta ningún parámetro.
implementation.dart
@override
Stream<NumberTriviaState> mapEventToState(
NumberTriviaEvent event,
) async* {
if (event is GetTriviaForConcreteNumber) {
...
} else if (event is GetTriviaForRandomNumber) {
yield Loading();
final failureOrTrivia = await getRandomNumberTrivia(
NoParams(),
);
yield failureOrTrivia.fold(
(failure) => Error(message: _mapFailureToMessage(failure)),
(trivia) => Loaded(trivia: trivia),
);
}
}
Después de que este código de implementación haya pasado todas las pruebas, es hora de (redoble de batería, por favor ) ...
Remover los Duplicados
En realidad, no hay mucha duplicación para eliminar, pero aún así, podemos hacer que nuestro código sea un poco más DRY extrayendo la emisión de los estados Error y Loaded.
implementation.dart
@override
Stream<NumberTriviaState> mapEventToState(
NumberTriviaEvent event,
) async* {
if (event is GetTriviaForConcreteNumber) {
final inputEither =
inputConverter.stringToUnsignedInteger(event.numberString);
yield* inputEither.fold(
(failure) async* {
yield Error(message: INVALID_INPUT_FAILURE_MESSAGE);
},
(integer) async* {
yield Loading();
final failureOrTrivia = await getConcreteNumberTrivia(
Params(number: integer),
);
yield* _eitherLoadedOrErrorState(failureOrTrivia);
},
);
} else if (event is GetTriviaForRandomNumber) {
yield Loading();
final failureOrTrivia = await getRandomNumberTrivia(
NoParams(),
);
yield* _eitherLoadedOrErrorState(failureOrTrivia);
}
}
Stream<NumberTriviaState> _eitherLoadedOrErrorState(
Either<Failure, NumberTrivia> either,
) async* {
yield either.fold(
(failure) => Error(message: _mapFailureToMessage(failure)),
(trivia) => Loaded(trivia: trivia),
);
}
String _mapFailureToMessage(Failure failure) {
switch (failure.runtimeType) {
case ServerFailure:
return SERVER_FAILURE_MESSAGE;
case CacheFailure:
return CACHE_FAILURE_MESSAGE;
default:
return 'Unexpected Error';
}
}
Este tipo de refactorización nos insta a volver a ejecutar las pruebas una vez más y, por supuesto, todas pasan. Y con esta refactorización, acabamos de terminar otra parte de nuestra aplicación Number Trivia: el titular de la lógica de presentación en forma de BLoC.
¿Qué sigue?
Nos estamos acercando a la línea de meta. Ahora que hemos implementado literalmente toda la lógica, es hora de unir todas las partes con la inyección de dependencia y luego ... ¡Ya no nos quedará nada más que crear que la interfaz de usuario!
Aquí les comparto el video
Deja una respuesta
Lo siento, debes estar conectado para publicar un comentario.