flutter

Flutter Riverpod 테스트 케이스 활용 + mockito

bakerlee 2023. 6. 10. 16:02

우선 테스트 케이스를 구성하는데 있어서 중요한 부분은 테스트의 범위를 정확히 설정하는 것 이다.

좋은 함수란 하나의 기능을 확실하게 잘 수행하는 함수이며, 이는 테스트 케이스 역시 해당하는 부분이다. 

우선 로직이 복잡하게 구성되어 있다면 테스트 케이스는 잘 구현될 수 없다. 로직의 품질과 테스트 케이스의 품질은 정비례한다는 것을 먼저 알고 코드를 구성해야 한다.

테스트를 할 코드는 다음과 같다. 

@riverpod
class SampleStateNotifier extends _$SampleStateNotifier {
  late final Repository repository;

  @override
  BaseSampleState build() {
    repository = ref.watch(clientRepositoryProvider);
    return SampleLoading();
  }

  getData() async {
    try {
      await repository.request();
      state = SampleSuccess();
    } on CustomException catch (e) {
      state = SampleError();
    } on Exception catch (e) {
      state = SampleError();
    }
  }
}

위의 코드는 테스트를 위해 작성한 코드이며 서버를 호출하는 코드와 해당 응답에 대한 반환값을 처리하는 코드라고 생각하면 된다.

해당 코드의 정확한 기능은 호출을 실행하고 해당 호출의 반환의 처리 이다. 
즉, 성공하면 상태를 success로 실패하거나 거부되면 error로 상태를 변화시켜 주면 된다. 

클린아키택처 기반으로 본다면 위의 코드는 usecase에 대항하는 부분이며  repository 즉 서버의 호출은 external interface에 해당하는 부분다. 

서버의 호출은 외부의 영역이며 해당 코드의 동작이 아니다. 

논리적으로 생각해도 테스트를 한번 할때마다 일일히 서버 호출을 하는건 좋은 방법이 아니다. 
프로그램은 성공할 때만 동작하면 안되며 오류가 발생해도 돌아가야 한다.  즉, 오류가 발생하는 상황에 대하여서도 테스트를 진행해야 하는데 테스트를 해보겠다고 임의로 오류를 발생시키는건 너무 번거롭고 위험할 수 있는 행위이다. 

이런 상황에서 mockito같은 도구를 사용하여 실제 동작이 아닌 가상의 동작을 정의하고 테스트를 할 수 있다. 

dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.0.2
  riverpod_annotation: ^1.0.4
  
  mockito: ^5.4.0

 

대략적인 사용법은 간단하다 
해당 패키지는 builder_runner를 통한 코드 생성을 지원하므로 다음과 같이 생성할 클래스를 어노테이션에 명시해주면된다.

import 'provider_test.mocks.dart';

@GenerateMocks([Repository])
void main() {
...
  group("provider test", () {
    final MockRepository repository = MockRepository();
    final container = ProviderContainer();
    late SampleStateNotifier notifier;

    setUpAll(() {
      notifier = container.read(sampleStateNotifierProvider.notifier);
    });

    group("request is exception", () {
      ProviderContainer container =
          ProviderContainer(overrides: [clientRepositoryProvider.overrideWithValue(repository)]);
      when(repository.request()).thenThrow(Exception());

      setUp(() {});
      test("request fail", () async {
        final sampleState = container.read(sampleStateNotifierProvider.notifier);

        expect(sampleState.state, isA<SampleLoading>());
        await sampleState.getData();

        expect(sampleState.state, isA<SampleError>());
      });

      test("request is error", () async {
        final sampleState = container.read(sampleStateNotifierProvider.notifier);

        expect(sampleState.state, isA<SampleLoading>());
        await sampleState.getData();
        expect(sampleState.state, isA<SampleError>());
      });
    });

...

build_runner를 실행시키면 Repository의 Mock인 MockRepository가 생성되며 해당 클래스를 사용하면 테스트가 가능하다. 

Mock클래스는 원형이 되는 클래스의 메서드들의 추상화된 객체들을 가지고 있다.

내가 여기에서 테스트를 하고 싶은 범위는 sampleStateNotifierProvider  이며 해당 프로바이더는 Repository에 대한 의존성을 가지고 있다. 따라서 테스트 외부의 범위인 Repository의 동작은 Mocking을 하는 것이다. 

@riverpod
class SampleStateNotifier extends _$SampleStateNotifier {
  late final Repository repository;

  @override
  BaseSampleState build() {
    repository = ref.watch(clientRepositoryProvider);
    return SampleLoading();
  }

  getData() async {
    try {
      state = SampleLoading();
      final resp = await repository.request();
      state = SampleSuccess(code: resp);
    } on CustomException catch (e) {
      state = SampleError();
    } on Exception catch (e) {
      state = SampleError();
    }
  }
}

 

우선 ProviderContianer를 설정할 필요가 있다. 
Riverpod은 ProviderContainer(Scope)를 통해서 동작하는 프로세스이다. 테스트 내부적으로 동작한 컨테이너를 설정해야 한다. 

ProviderContainer container =
    ProviderContainer(overrides: [clientRepositoryProvider.overrideWithValue(mockRepository)]);

여기서 ovverides 인자를 통해 특정 provider가 반환할 구현체를 변경할 수 있다. 여기서 mockRepository를 할당하면 
ref.watch(clientRepositoryProvider) 의 반환값이 mockRepository 가 되도록 설정된다. 

when(mockRepository.request()).thenThrow(Exception());

그 다음으로 해야 할 동작은 mocking 된 구현체 내부의 세부적인 동작을 정의하는 것이다. 

위의 코드는 오류가 발생한 경우를 가정하는 테스트이다. 따라서 위의 코드와 같이 해당 구현체 내부의 request 메서드가 호출될 때 오류가 발생한다고 가정하는 것이다. 만약 성공이라면 thenReturn, thenAnswer 와 같은 동작을 정의하면 된다 

test("request is error", () async {
  final sampleState = container.read(sampleStateNotifierProvider.notifier);

  expect(sampleState.state, isA<SampleLoading>());
  await sampleState.getData();
  expect(sampleState.state, isA<SampleError>());
});

아래의 코드 실행 시 초기 state가 loading이며 getData 함수 호출 시 내부에서 오류가 발생한 경우 state가 StateError로 변경됨을 검사한다. isA<>() 를 통해 state의 비교를 할 수 있다. 

test("success", () async {
  const fakeCode = 100;
  when(mockRepository.request()).thenReturn(Future.value(fakeCode));
  expect(notifier.state, isA<SampleLoading>());
  await notifier.getData();
  verify(mockRepository.request());
  expect(
      notifier.state, isA<SampleSuccess>().having((state) => state.code, "code", fakeCode));
});

isA<>를 통해서 state를 비교하는 경우 위와 같이 내부 값이 정확하게 할당되었는지까지 확인 가능하다 

이렇게 내부 의존성을 가진 동작들을 실제로 실행하지 않고 필요한 상황을 가정하는 방식으로 테스트를 진행할 수 있다.

추가로 verify와 같은 도구를 통해 mocking된 클래스의 특정 메서드가 실행되었는지, 그러지 않았는지 까지 확인이 가능하다. 

 

 

서비스의 범위가 넓어질수록, 품질을 높일수록 테스트의 가짓수는 늘어나고 일일히 재현하기도 힘든 부분이 있다. 

프로그램은 요청이 성공할 때만 동작하면 안된다. 오류가 발생하여도 가정된 흐름 안에서 원활한 동작을 보장할 수 있어야 한다.  

가령, 서비스가 운영중인 단계에서 서버에서 특정 오류가 발생하거나 통신이 원활하지 않은 상태를 테스트하고 싶다고 한다고 가정해보자. 테스트를 하겠다고 서버를 중단할 수는 없고 특정 인자를에 대하여 무조건 오류를 내려보내거나 특정 계정에 대하여 무조건 오류를 설정할 수 있을 것이다. 그런데 이런 동작은 생각하는 것 이상으로 훨씬 귀찮고 낭비가 많을 수 있다. 코드 반영에 일정 이상의 시간이 걸릴 수도 있고 실수라도 발생하면 훨씬 좋지 못한 상황이 발생할 수 있다. 결정적으로 테스트에 들어가는 인적 비용이 커질 수 있다. 

이러한 여러가지 이유로 인해 mock데이터를 활용한 테스트는 반드시 필요한 작업이다. 

또한 이렇게 만들어지는 테스트 코드를 잘 작성하려면 적절한 수준의 의존성 주입과 각 클래스, 함수의 단일책임원칙이 보장되어야 한다. 
 

https://pub.dev/packages/mockito

 

mockito | Dart Package

A mock framework inspired by Mockito with APIs for Fakes, Mocks, behavior verification, and stubbing.

pub.dev

https://docs-v2.riverpod.dev/ko/docs/cookbooks/testing

 

테스트 | Riverpod

중, 대규모 애플리케이션에서 테스트는 중요한 작업입니다.

riverpod.dev

https://github.com/BowonLee/state_management_practice

 

GitHub - BowonLee/state_management_practice: 상태관리 라이브러리 관련 학습

상태관리 라이브러리 관련 학습. Contribute to BowonLee/state_management_practice development by creating an account on GitHub.

github.com