[Flutter] : TDD - 11. Bloc Implementation


우리는 이제 state와 event에 이어서 bloc을 개발할 것이다.

TDD기반으로 개발하기 전에 먼저 생각해보자.

Bloc은 Domain과 Presentation 사이에 있어야 한다. (Presentation Logic Holders)

그렇다면 우리 앱은 2개의 Use cases에 종속성이 생긴다.

이미지

number_trivia_bloc.dart

class NumberTriviaBloc extends Bloc<NumberTriviaEvent, NumberTriviaState> {
  final GetConcreteNumberTrivia getConcreteNumberTrivia;
  final GetRandomNumberTrivia getRandomNumberTrivia;
  final InputConverter inputConverter;

  NumberTriviaBloc(
      {@required GetConcreteNumberTrivia concrete,
      @required GetRandomNumberTrivia random,
      @required this.inputConverter})
      : assert(concrete != null),
        assert(random != null),
        assert(inputConverter != null),
        getConcreteNumberTrivia = concrete,
        getRandomNumberTrivia = random,
        super(Empty());

  @override
  Stream<NumberTriviaState> mapEventToState(
    NumberTriviaEvent event,
  ) async* {
    // TODO: implement mapEventToState
  }
}

자 이제 먼저 테스트 코드부터 짜자. 폴더랑 파일 위치는 test에 거울처럼 똑같이 따라 만든다.

test.dart

class MockGetConcreteNumberTrivia extends Mock
    implements GetConcreteNumberTrivia {}

class MockGetRandomNumberTrivia extends Mock implements GetRandomNumberTrivia {}

class MockInputConverter extends Mock implements InputConverter {}

void main() {
  NumberTriviaBloc numberTriviaBloc;
  MockGetConcreteNumberTrivia mockGetConcreteNumberTrivia;
  MockGetRandomNumberTrivia mockGetRandomNumberTrivia;
  MockInputConverter mockInputConverter;

  setUp(() {
    mockGetConcreteNumberTrivia = MockGetConcreteNumberTrivia();
    mockGetRandomNumberTrivia = MockGetRandomNumberTrivia();
    mockInputConverter = MockInputConverter();
    numberTriviaBloc = NumberTriviaBloc(
        concrete: mockGetConcreteNumberTrivia,
        random: mockGetRandomNumberTrivia,
        inputConverter: mockInputConverter);
  });
}

자 테스트 코드 준비가 끝났다.

그럼 케이스를 나눠서 테스크 코드와 구현을 진행해보자.

Initial State


먼저 최초 상태이다. 최초 상태는 Empty로 시작되어야 한다.

test.dart

test('initialState should be Empty', () {
    // assert
    expect(numberTriviaBloc.state, equals(Empty()));
  });

여기서는 implementation을 안해줘도 된다.

왜냐하면 bloc extension이 짜준 기본코드에 구현이 이미 되있기 때문이다.

1. GetTriviaForConcreteNumber


test.dart

group('GetTriviaForConcreteNumber', () {
    // event가 입력할 문자
    final tNumberString = '1';
    // InputConverter로 부터 들어올 데이터
    final tNumberParsed = int.parse(tNumberString);
    // Test용 NumberTrivia
    final tNumberTrivia = NumberTrivia(number: 1, text: 'test trivia');
  });

테스트를 진행하기 앞서서 이 테스트에서 가장 중요한 것은 UI로 부터 제대로 된 데이터 (양의 숫자)가 들어온 다는 가정이 필요하다. 이건 InputConverter로 구현하였고 테스트도 끝냈다. 그렇기에 단일 책임 원칙을 준수하며 테스트를 진행한다.

첫 테스트는 InputConverter가 제대로 호출이 됬는지 확인한다.

1-1. InputConverter


test.dart

test(
        'should call the InputConverter to validate and convert the string to an unsigned integer',
        () async {
      // arrange
      when(mockInputConverter.stringToUnsignedInteger(any))
          .thenReturn(Right(tNumberParsed));
			when(mockGetConcreteNumberTrivia(any))
          .thenAnswer((_) async => Right(tNumberTrivia));
      // act
      numberTriviaBloc.add(GetTriviaForConcreteNumber(tNumberString));
      await untilCalled(mockInputConverter.stringToUnsignedInteger(any));

      verify(mockInputConverter.stringToUnsignedInteger(tNumberString));
    });

untilCalled 로 함수가 호출 될 때 까지 기다려 검증한다.

impl.dart

@override
  Stream<NumberTriviaState> mapEventToState(
    NumberTriviaEvent event,
  ) async* {
    if (event is GetTriviaForConcreteNumber) {
      inputConverter.stringToUnsignedInteger(event.numberString);
    }
  }

state가 stream으로 들어온다.

들어온 state가 GetTriviaForConcreteNumber면 inputConverter를 실행하게 하였다.

Test 통과

여기서 Converter가 정상적으로 동작하면 GetConcreteNumberTrivia Usecase가 동작할 것이다.

그런데 Converter에서 에러가 발생하면 어떻게 해야하나?

Error 처리를 해줘야한다 그리고 UI에 보여줘야 한다.

즉 Error State로 넘어간다.

각 Error 마다 설명이 필요하다.

test.dart

const String SERVER_FAILURE_MESSAGE = 'Server Failure';
const String CACHE_FAILURE_MESSAGE = 'Cache Failure';
const String INVALID_INPUT_FAILURE_MESSAGE =
    'Invalid Input - The number must be a positive integer or zero.';

1-2. InputFailure


이제 에러를 test 하기 앞서서 이전과 다른 test 방식을 도입해야한다.

이전에는 함수를 호출하면 정상적인 값 혹은 에러 둘중에 하나가 반환되었다.

하지만 Stream은 호출하면 아무것도 돌아오지 않고

Listen하고 있는 쪽에 데이터가 나온다.

테스트 코드를 보며 이해해보자.

test.dart

test('should emit [Error] when the input is invalid', () async {
      // arrange
      when(mockInputConverter.stringToUnsignedInteger(any))
          .thenReturn(Left(InvalidInputFailure()));
      // assert later
      final expected = [
        Empty(),
        Error(message: INVALID_INPUT_FAILURE_MESSAGE),
      ];
      expectLater(numberTriviaBloc.state, emitsInOrder(expected));
      // act
      numberTriviaBloc.add(GetTriviaForConcreteNumber(tNumberString));
    });

내용을 보면 평상시와 순서가 다르다

시작은 똑같이 arrange로 시작하지만

assert를 중간에 진행한다. ⇒ Stream으로 들어올 값을 예상하여 순서대로 List에 넣어주었다.

그리고 act에서 데이터를 넣어준다.

impl.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) => throw UnimplementedError());
    }
  }

async vs async* ⇒ async는 비동기 처리이고 async*은 비동기 처리에서 yield(중간 데이터 반환)을 지원한다.

yield vs yield* ⇒ yield는 반복중에 데이터를 여러번 반환하고 yield*는 stream에서 재귀를 탈 때 사용된다.

1-3. Testing Usecase


이제 UseCase로 부터 데이터가 잘 호출되는지 테스트 해보자.

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
      numberTriviaBloc.add(GetTriviaForConcreteNumber(tNumberString));
      await untilCalled(mockGetConcreteNumberTrivia(any));
      // assert
      verify(mockGetConcreteNumberTrivia(Params(number: tNumberParsed)));
    });

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

지금은 에러 투성이다. 일단 진행하자.

1-4. Loading, Loaded


데이터를 성공적으로 가져오는 동안 로딩화면과 가져왔다면 로드된 화면을 보여줘야 한다.

우리는 이미 Loading State와 Loaded State를 만들었따.

test.dart

test('should emit [Loading, Loaded] when data is gotten successfully',
        () async {
      // arrange
      when(mockInputConverter.stringToUnsignedInteger(any))
          .thenReturn(Right(tNumberParsed));
      when(mockGetConcreteNumberTrivia(any))
          .thenAnswer((_) async => Right(tNumberTrivia));
      // assert later
      final expected = [
        Empty(),
        Loading(),
        Loaded(trivia: tNumberTrivia),
      ];
      expectLater(numberTriviaBloc.state, emitsInOrder(expected));
      // act
      numberTriviaBloc.add(GetTriviaForConcreteNumber(tNumberString));
    });

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

1-5. Loading Error


로드중에 에러가 발생하는 케이스를 해보자.

test.dart

est('should emit [Loading, Error] when getting data fails', () async {
      // arrange
      when(mockInputConverter.stringToUnsignedInteger(any))
          .thenReturn(Right(tNumberParsed));
      when(mockGetConcreteNumberTrivia(any))
          .thenAnswer((_) async => Left(ServerFailure()));
      // assert later
      final expected = [
        Empty(),
        Loading(),
        Error(message: SERVER_FAILURE_MESSAGE),
      ];
      expectLater(numberTriviaBloc.state, emitsInOrder(expected));
       // act
      numberTriviaBloc.add(GetTriviaForConcreteNumber(tNumberString));
    });

impl.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: SERVER_FAILURE_MESSAGE),
            (trivia) => Loaded(trivia: trivia));
      });
    }
  }

1-6. Cache Error


캐시 데이터를 가져올 때 에러가 발생하는 경우를 처리해보자.

test.dart

test(
        'should emit [Loading, Error] with a proper message for the error when getting data fails',
        () async {
      // arrange
      when(mockInputConverter.stringToUnsignedInteger(any))
          .thenReturn(Right(tNumberParsed));
      when(mockGetConcreteNumberTrivia(any))
          .thenAnswer((_) async => Left(CacheFailure()));
      // assert later
      final expected = [
        Empty(),
        Loading(),
        Error(message: CACHE_FAILURE_MESSAGE),
      ];
      expectLater(numberTriviaBloc.state, emitsInOrder(expected));
      // act
      numberTriviaBloc.add(GetTriviaForConcreteNumber(tNumberString));
    });

impl.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: failure is ServerFailure
                    ? SERVER_FAILURE_MESSAGE
                    : CACHE_FAILURE_MESSAGE),
            (trivia) => Loaded(trivia: trivia));
      });
    }
  }

ERROR 메시지 처리를 삼항 연산자로 하였다. 이 부분에서 에러 종류가 추가 될 수도 있으니

리팩토링을 해주자.

String _mapFailureToMessage(Failure failure) {
    switch (failure.runtimeType) {
      case ServerFailure:
        return SERVER_FAILURE_MESSAGE;
      case CacheFailure:
        return CACHE_FAILURE_MESSAGE;
      default:
        return 'Unexpected Error';
    }
  }

수정할 점 발견

진행하다보니 flutter_bloc 버전을 최신으로 올리게 되면서 위의 소스코드와 강의의 내용이 조금씩 달라지게 되었다. 그러면서 또 생긴 오류가 있다.

initialState를 분명히 주었지만 expectlater 함수에서 계속 Empty()만 빠져서 오는걸 확인했다.

나중에 이 부분은 수정하도록 하자.

2. getTriviaForRandomNumber


위의 내용과 거의 비슷하고 InputConveter 부분이 빠져서 오히려 간단하다.

test.dart

group('GetTriviaForRandomNumber', () {
    // Test용 NumberTrivia
    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
      numberTriviaBloc.add(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(numberTriviaBloc, emitsInOrder(expected));
      // act
      numberTriviaBloc.add(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(numberTriviaBloc, emitsInOrder(expected));
      // act
      numberTriviaBloc.add(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(numberTriviaBloc, emitsInOrder(expected));
      // act
      numberTriviaBloc.add(GetTriviaForRandomNumber());
    });
  });

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

테스트를 모두 정상적으로 통과한다.

이제 중복되는 부분들을 refactoring 해보자.

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

이제 다 끝났다!

의존성 주입 부분과 UI 부분을 처리해보자.