1

Flutter TDD Clean Architecture Course [12] – Bloc Implementation 2/2

We started to implement the NumberTriviaBloc in the previous part and you learned the basics of doing TDD with Streams. In this part, let's finish the Bloc implementation so that we can move on to dependency injection next.

TDD Clean Architecture Course
This post is just one part of a tutorial series. See all of the other parts here and learn to architect your Flutter apps!

Continuing Where We Left Off

We already implemented an important part of the logic which runs whenever the GetTriviaForConcreteNumber event arrives in the Bloc - input conversion. However, after we have the successfully converted and validated int for which the user wants to see some amazing number trivia, we currently just throw an UnimplementedException. Let's change that!

Testing with a Use Case

The first thing that should happen is that the GetConcreteNumberTrivia use case should be executed with a proper number argument. For example, if the event's numberString is "42", the integer passed into the use case should of course be 42.

Notice that we now have to mock both the InputConverter and the GetConcreteNumberTrivia use case in the arrange part of the test.​​

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

We're again going to implement just enough code for the test to pass. We can safely ignore all the warnings in the IDE for now saying that the method doesn't return a 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));
      },
    );
  }
}

Refactoring the Tests

Mocking the InputConverter to return a successful value

when(mockInputConverter.stringToUnsignedInteger(any))
    .thenReturn(Right(tNumberParsed));

will happen in almost every test. It's a good idea to put it into its own method setUpMockInputConverterSuccess and use that throughout all the tests, including the one from the previous part.

number_trivia_bloc_test.dart

group('GetTriviaForConcreteNumber', () {
  ...

  void setUpMockInputConverterSuccess() =>
      when(mockInputConverter.stringToUnsignedInteger(any))
          .thenReturn(Right(tNumberParsed));
  // Use in tests below
  ...
}

Successful Execution

Of course, just simply executing the use case is not enough. We need to let the UI know what's going on so that it can display something to the user. Which states should the Bloc emit when the use case executes smoothly?

Well, before calling the use case, it's a good idea to show some visual feedback to the user. While displaying a CircularProgressIndicator is a task for the UI, we have to notify the UI to show it by emitting a Loading state.

After the use case returns the Right side of Either (which means success), the Bloc should pass the obtained NumberTrivia to the UI inside a Loaded state.

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

The implementation will start to get ugly with all the nesting, but we're of course going to refactor it after we're done writing tests.

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

Unsuccessful Execution

The Either type makes us handle the Failure case, but as of now, we're simply throwing an UnimplementedError when this happens. Let's create a test for the case when the use case (and consequently the repository and so on down the chain) returns a ServerFailure.

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, isn't it? ServerFailure is not the only kind of Failure returned by the use case though. A CacheFailure can also occur and we have to handle that.

It's very important to handle all the possible subtypes of Failure inside a Bloc. After all, we want to show a meaningful error message to the user and that's possible only by emitting Error states with a customized message.

Test for the CacheFailure will be very similar to the previous one, yet different enough that refactoring is not really needed.

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

We're about to get to some really nasty looking code. Don't worry though as we're going to refactor it in just a little while.

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

Refactoring the Ternary Operator

The way we decide on the message of the Error state leaves a lot to be desired. The ternary operator is actually an unsafe choice - what if there's some other Failure subtype which we're not aware of. I mean, it shouldn't happen with Clean Architecture, but still... No matter the actual type of the Failure, unless it's a ServerFailure, it's now always going to have a CACHE_FAILURE_MESSAGE assigned to it.

Let's create a separate _mapFailureToMessage helper method where we're going to try to handle the messages more appropriately. We're still within the confines of vanilla Dart which doesn't support  sealed classes out of the box, so we still need to handle the unexpected Failure type (just in case).

The whole mapEventToState method now looks like this:

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

Getting the Random Trivia

As we're already used to from the previous parts, we're going to intentionally break TDD principles so that we don't drive ourselves crazy with tediously implementing basically the same code for the second time. Unlike with the data sources or the repository, both the test code and the production code for the GetTriviaForRandomNumber will be simpler. That's because it doesn't use the InputValidator! No user-inputted number is needed to get random number trivia, after all.

Starting with the tests, we're going to copy and paste the code from the concrete group, remove everything dealing with the InputConverter and also change any references to the concrete use case or event to reference the random use case or event.

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

With all of these tests failing for obvious reasons, we're going to copy the implementation for when the GetTriviaForRandomNumber arrives at the Bloc from the concrete counterpart. Of course, we don't handle any input conversion here, so we're going to copy only the code which deals with the already-converted integer.​​

There are only two changes to make in this copied code - call the GetRandomNumberTrivia use case and pass in NoParams() since the use case, well ..., doesn't accept any parameters 😉

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

After this implementation code made all of the tests pass, it's time for (drumroll please 🥁)...

Removing the Duplication

There's actually not that much duplication to remove, but still, we can make our code a bit more DRY by extracting the emitting of the Error and Loaded states.

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

This kind of a refactoring urges us to rerun the tests once again and, of course, they still all pass. And with this refactoring, we've just finished another part of our Number Trivia App - the presentation logic holder (should I say PLoC 😅)​​ in the form of a BLoC.​​

Coming Up Next

We're getting close to the finish line. Now that we have literally every bit of logic implemented, it's time to put all the parts together with dependency injection and then... There will be nothing more left for us to do than to create the UI!

Icons and other attribution GOES HERE
Matej Rešetár
 

Matej is an app developer with a knack for teaching others. If he's not programming, making tutorials or doing other business, he's mostly working out, listening to audiobooks and taking cold showers.

  • Dan says:

    Thanks so much for taking the time to put this series together! It’s a lot of info, but you will see the beauty of Clean architecture.

    To anyone going through this, and if you’ve downloaded the sample app, after you update flutter_bloc and equatable you’ll have to replace calls to bloc.dispatch() with bloc.add(). I did this but the bloc tests failed, saying they were expecting a Stream but didn’t get one. When I changed
    expectLater(bloc.state, emitsInOrder(expected) with
    expectLater(bloc, emitsInOrder(expected) it worked. Since the bloc is a Stream, and emitsInOrder() is checking a Stream, not a State.
    Now all tests pass, in the sample and in the project I’m converting to Clean.

  • >