🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

Riverpod 테스팅 전략 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 11. 30. · 23 Views

Riverpod 테스팅 전략 완벽 가이드

Flutter 앱에서 Riverpod을 사용할 때 효과적으로 테스트를 작성하는 방법을 다룹니다. ProviderContainer를 활용한 단위 테스트부터 통합 테스트까지, 실무에서 바로 적용할 수 있는 테스팅 전략을 배워봅니다.


목차

  1. ProviderContainer로_단위_테스트
  2. Provider_오버라이드로_모킹
  3. AsyncNotifier_테스트
  4. 위젯_테스트와_Riverpod
  5. 통합_테스트_전략
  6. 테스트_커버리지_높이기

1. ProviderContainer로 단위 테스트

김개발 씨는 Riverpod으로 상태 관리를 구현한 후 뿌듯한 마음으로 퇴근 준비를 하고 있었습니다. 그때 팀장님이 다가와 물었습니다.

"테스트 코드는 작성했어요?" 순간 김개발 씨의 얼굴이 굳었습니다. 위젯 없이 Provider만 어떻게 테스트하지?

ProviderContainer는 위젯 트리 없이도 Provider를 생성하고 테스트할 수 있게 해주는 핵심 도구입니다. 마치 시험관에서 세포를 배양하듯, 격리된 환경에서 Provider의 동작을 검증할 수 있습니다.

이를 통해 빠르고 안정적인 단위 테스트가 가능해집니다.

다음 코드를 살펴봅시다.

// 카운터 상태를 관리하는 간단한 Provider
final counterProvider = StateNotifierProvider<CounterNotifier, int>((ref) {
  return CounterNotifier();
});

class CounterNotifier extends StateNotifier<int> {
  CounterNotifier() : super(0);
  void increment() => state++;
  void decrement() => state--;
}

// 테스트 코드
void main() {
  test('카운터가 정상적으로 증가하는지 확인', () {
    // ProviderContainer로 격리된 테스트 환경 생성
    final container = ProviderContainer();
    addTearDown(container.dispose);

    // Provider 읽기 및 동작 테스트
    expect(container.read(counterProvider), 0);
    container.read(counterProvider.notifier).increment();
    expect(container.read(counterProvider), 1);
  });
}

김개발 씨는 입사 6개월 차 주니어 개발자입니다. Flutter와 Riverpod을 배우며 열심히 앱을 개발해왔지만, 테스트 코드는 늘 뒷전이었습니다.

"일단 동작하면 되지"라는 생각으로 넘겼던 것입니다. 그런데 오늘 팀장님의 한마디가 김개발 씨의 마음을 무겁게 했습니다.

"테스트 없는 코드는 사상누각이에요. 언제 무너질지 모르죠." 선배 개발자 박시니어 씨가 김개발 씨에게 다가왔습니다.

"Riverpod 테스트, 생각보다 어렵지 않아요. ProviderContainer만 알면 돼요." 그렇다면 ProviderContainer란 정확히 무엇일까요?

쉽게 비유하자면, ProviderContainer는 마치 과학 실험실의 시험관과 같습니다. 실제 인체에서 실험하기 어려운 것들을 시험관에서 격리해 테스트하듯이, 위젯 트리라는 복잡한 환경 없이도 Provider만 따로 꺼내 테스트할 수 있게 해줍니다.

일반적으로 Riverpod의 Provider는 위젯 트리 안에서 동작합니다. ProviderScope라는 위젯이 모든 Provider의 상태를 관리하기 때문입니다.

하지만 테스트할 때마다 위젯을 만들어야 한다면 너무 번거롭겠죠? 바로 이런 문제를 해결하기 위해 ProviderContainer가 존재합니다.

ProviderScope의 내부에서 실제로 Provider들을 관리하는 것이 바로 ProviderContainer이기 때문입니다. 위의 코드를 살펴보겠습니다.

먼저 **ProviderContainer()**를 호출해 새로운 컨테이너를 생성합니다. 이 컨테이너는 독립적인 Provider 환경을 제공합니다.

다른 테스트에 영향을 주지 않는 깨끗한 슬레이트입니다. **addTearDown(container.dispose)**는 테스트가 끝난 후 리소스를 정리하는 코드입니다.

메모리 누수를 방지하는 중요한 습관입니다. **container.read()**를 사용하면 Provider의 현재 상태를 읽을 수 있습니다.

**container.read(counterProvider.notifier)**로 Notifier에 접근해 메서드를 호출할 수도 있습니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 장바구니 기능을 개발한다고 가정해봅시다. 상품 추가, 수량 변경, 삭제 등 다양한 기능을 ProviderContainer로 빠르게 검증할 수 있습니다.

위젯 렌더링 없이 순수한 로직만 테스트하니 실행 속도도 빠릅니다. 하지만 주의할 점도 있습니다.

ProviderContainer는 각 테스트마다 새로 생성해야 합니다. 같은 컨테이너를 여러 테스트에서 재사용하면 상태가 오염되어 예상치 못한 결과가 나올 수 있습니다.

박시니어 씨의 설명을 들은 김개발 씨는 눈이 반짝였습니다. "생각보다 간단하네요!

바로 적용해볼게요."

실전 팁

💡 - 각 테스트마다 새로운 ProviderContainer를 생성하여 테스트 격리를 보장하세요

  • addTearDown을 반드시 사용해 리소스 누수를 방지하세요
  • container.read()는 상태를 한 번 읽고, container.listen()은 변화를 감지합니다

2. Provider 오버라이드로 모킹

김개발 씨는 단위 테스트의 기초를 익혔습니다. 그런데 새로운 문제가 생겼습니다.

테스트하려는 Provider가 API 서버에서 데이터를 가져오는데, 테스트할 때마다 실제 서버를 호출할 수는 없는 노릇입니다. 박시니어 씨가 힌트를 줍니다.

"오버라이드를 활용해봐요."

Provider 오버라이드는 테스트 환경에서 실제 Provider를 가짜(Mock) Provider로 교체하는 기법입니다. 마치 영화 촬영에서 위험한 장면을 스턴트맨이 대신하듯, 테스트에서는 Mock이 실제 의존성을 대신합니다.

이를 통해 외부 의존성 없이 안정적인 테스트가 가능해집니다.

다음 코드를 살펴봅시다.

// 실제 API를 호출하는 Repository
final userRepositoryProvider = Provider<UserRepository>((ref) {
  return RealUserRepository(); // 실제 구현
});

// 사용자 정보를 가져오는 Provider
final userProvider = FutureProvider<User>((ref) async {
  final repository = ref.watch(userRepositoryProvider);
  return repository.getUser();
});

// 테스트용 Mock Repository
class MockUserRepository implements UserRepository {
  @override
  Future<User> getUser() async {
    return User(id: 1, name: '테스트유저');
  }
}

// 테스트 코드
void main() {
  test('사용자 정보를 정상적으로 가져오는지 확인', () async {
    final container = ProviderContainer(
      overrides: [
        // 실제 Repository를 Mock으로 교체
        userRepositoryProvider.overrideWithValue(MockUserRepository()),
      ],
    );
    addTearDown(container.dispose);

    final user = await container.read(userProvider.future);
    expect(user.name, '테스트유저');
  });
}

김개발 씨가 만든 앱에는 사용자 정보를 서버에서 가져오는 기능이 있습니다. 테스트를 작성하려는데 문제가 생겼습니다.

테스트할 때마다 실제 서버를 호출하면 어떻게 될까요? 첫째, 서버가 다운되면 테스트도 실패합니다.

둘째, 네트워크 상태에 따라 결과가 달라질 수 있습니다. 셋째, 테스트 속도가 느려집니다.

이런 테스트는 신뢰할 수 없는 테스트가 됩니다. 박시니어 씨가 해결책을 알려줍니다.

"의존성 주입이라는 개념을 활용하면 돼요. Riverpod에서는 오버라이드로 쉽게 구현할 수 있어요." 오버라이드란 무엇일까요?

쉽게 비유하자면, 마치 대역 배우와 같습니다. 위험한 액션 장면에서 주연 배우 대신 스턴트맨이 연기하듯이, 테스트에서는 실제 API 대신 Mock이 그 역할을 대신합니다.

결과물은 동일하게 나오지만, 훨씬 안전하고 통제된 환경에서 촬영할 수 있습니다. 위의 코드를 살펴보겠습니다.

먼저 UserRepository 인터페이스를 정의하고, 실제 구현체인 RealUserRepository를 만듭니다. 테스트에서는 이 인터페이스를 구현한 MockUserRepository를 사용합니다.

핵심은 ProviderContainer를 생성할 때 overrides 파라미터를 전달하는 것입니다. **userRepositoryProvider.overrideWithValue()**를 사용하면 실제 Provider가 반환하는 값 대신 Mock 객체를 사용하게 됩니다.

이제 userProvider가 동작할 때, RealUserRepository가 아닌 MockUserRepository를 사용하게 됩니다. 서버 호출 없이도 사용자 정보를 가져오는 로직을 완벽하게 테스트할 수 있습니다.

실무에서 이 패턴은 정말 자주 사용됩니다. 결제 시스템을 테스트한다고 생각해보세요.

테스트할 때마다 실제 결제가 이뤄지면 큰일입니다. Mock을 사용하면 결제 성공, 실패, 타임아웃 등 다양한 시나리오를 안전하게 테스트할 수 있습니다.

오버라이드에는 여러 가지 방법이 있습니다. **overrideWithValue()**는 고정된 값으로 교체할 때, **overrideWith()**는 새로운 Provider 함수로 교체할 때 사용합니다.

상황에 맞게 선택하면 됩니다. 주의할 점이 있습니다.

Mock 객체는 실제 객체와 동일한 인터페이스를 구현해야 합니다. 그래야 테스트 코드가 실제 환경과 동일하게 동작합니다.

김개발 씨는 고개를 끄덕였습니다. "이제 서버 상태와 관계없이 테스트할 수 있겠네요!"

실전 팁

💡 - 인터페이스를 정의하고 구현체를 분리하면 오버라이드가 쉬워집니다

  • overrideWithValue는 단순한 값 교체에, overrideWith는 동적인 로직이 필요할 때 사용하세요
  • 다양한 시나리오(성공, 실패, 예외)를 Mock으로 만들어 테스트하세요

3. AsyncNotifier 테스트

김개발 씨는 비동기 상태 관리를 위해 AsyncNotifier를 사용하고 있습니다. 로딩 상태, 성공 상태, 에러 상태를 우아하게 처리할 수 있어 마음에 들었습니다.

그런데 이 복잡한 상태 변화를 어떻게 테스트해야 할까요? 박시니어 씨가 말합니다.

"AsyncValue의 상태 변화를 추적하면 돼요."

AsyncNotifier 테스트는 비동기 작업의 전체 생명주기를 검증하는 것입니다. 마치 음식 배달 앱에서 주문 접수, 조리 중, 배달 중, 배달 완료를 추적하듯이, 로딩에서 성공 또는 실패까지의 상태 변화를 테스트합니다.

이를 통해 사용자 경험의 모든 단계를 검증할 수 있습니다.

다음 코드를 살펴봅시다.

// 게시글 목록을 관리하는 AsyncNotifier
@riverpod
class PostList extends _$PostList {
  @override
  Future<List<Post>> build() async {
    return _fetchPosts();
  }

  Future<List<Post>> _fetchPosts() async {
    final repository = ref.read(postRepositoryProvider);
    return repository.getPosts();
  }

  Future<void> refresh() async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(() => _fetchPosts());
  }
}

// 테스트 코드
void main() {
  test('게시글 로딩 상태 변화를 테스트', () async {
    final container = ProviderContainer(
      overrides: [
        postRepositoryProvider.overrideWithValue(MockPostRepository()),
      ],
    );
    addTearDown(container.dispose);

    // 초기 상태는 로딩
    expect(container.read(postListProvider).isLoading, true);

    // 비동기 작업 완료 대기
    await container.read(postListProvider.future);

    // 완료 후 데이터 확인
    final state = container.read(postListProvider);
    expect(state.hasValue, true);
    expect(state.value?.length, 3);
  });
}

김개발 씨의 앱에는 게시글 목록을 보여주는 화면이 있습니다. 사용자가 화면에 진입하면 로딩 스피너가 돌고, 데이터를 받아오면 목록이 표시됩니다.

네트워크 오류가 발생하면 에러 메시지가 나타납니다. 이런 복잡한 상태 변화를 어떻게 테스트할 수 있을까요?

박시니어 씨가 설명합니다. "AsyncNotifierAsyncValue라는 특별한 타입으로 상태를 관리해요.

이 AsyncValue가 가진 속성들을 활용하면 각 상태를 쉽게 검증할 수 있어요." AsyncValue를 음식 배달에 비유해볼까요? 배달 앱에서 주문을 하면 여러 상태를 거칩니다.

주문 접수 중, 조리 중, 배달 중, 그리고 배달 완료. AsyncValue도 마찬가지입니다.

loading(로딩 중), data(성공), error(실패)라는 세 가지 상태가 있습니다. 위의 코드를 살펴보겠습니다.

PostList라는 AsyncNotifier가 있습니다. build() 메서드에서 초기 데이터를 비동기로 가져옵니다.

refresh() 메서드는 데이터를 새로고침합니다. refresh() 메서드 내부를 주목해보세요.

먼저 **state = const AsyncValue.loading()**으로 로딩 상태를 설정합니다. 그다음 **AsyncValue.guard()**를 사용해 비동기 작업을 실행합니다.

guard()는 성공하면 data 상태로, 실패하면 error 상태로 자동 변환해줍니다. 테스트 코드에서는 이 상태 변화를 검증합니다.

isLoading은 현재 로딩 중인지, hasValue는 데이터가 있는지, hasError는 에러가 발생했는지 알려줍니다. **container.read(postListProvider.future)**는 비동기 작업이 완료될 때까지 기다립니다.

이 줄이 없으면 아직 로딩 중인 상태에서 테스트가 진행되어 실패할 수 있습니다. 에러 상태도 테스트해볼 수 있습니다.

Mock Repository에서 일부러 예외를 던지도록 설정하면, state.hasError가 true인지, state.error가 예상한 에러인지 검증할 수 있습니다. 실무에서 AsyncNotifier 테스트는 매우 중요합니다.

사용자는 로딩 중에 다른 화면으로 이동할 수도 있고, 네트워크가 끊길 수도 있습니다. 이런 예외 상황들을 테스트로 미리 검증해두면 안정적인 앱을 만들 수 있습니다.

김개발 씨가 물었습니다. "상태 변화 순서도 테스트할 수 있나요?" 박시니어 씨가 답합니다.

"물론이죠. container.listen()을 사용하면 상태 변화를 순서대로 기록해서 검증할 수 있어요."

실전 팁

💡 - AsyncValue.guard()를 사용하면 try-catch 없이도 에러를 자동으로 처리합니다

  • .future를 사용해 비동기 작업 완료를 기다린 후 상태를 검증하세요
  • 로딩, 성공, 실패 세 가지 상태를 모두 테스트하는 것이 좋습니다

4. 위젯 테스트와 Riverpod

김개발 씨는 Provider 로직 테스트에는 자신감이 생겼습니다. 하지만 진짜 사용자가 보는 것은 위젯입니다.

버튼을 탭하면 카운터가 증가하는지, 로딩 중에는 스피너가 보이는지, 이런 것들을 테스트해야 합니다. 박시니어 씨가 말합니다.

"위젯 테스트에서는 ProviderScope를 사용해요."

위젯 테스트는 실제 사용자 인터페이스가 Provider 상태에 따라 올바르게 동작하는지 검증합니다. 마치 자동차의 계기판이 엔진 상태를 정확히 표시하는지 확인하듯이, 위젯이 상태 변화를 올바르게 반영하는지 테스트합니다.

ProviderScope의 overrides를 활용하면 다양한 시나리오를 쉽게 재현할 수 있습니다.

다음 코드를 살펴봅시다.

// 카운터를 표시하는 위젯
class CounterPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);
    return Scaffold(
      body: Center(child: Text('$count', key: Key('counter'))),
      floatingActionButton: FloatingActionButton(
        onPressed: () => ref.read(counterProvider.notifier).increment(),
        child: Icon(Icons.add),
      ),
    );
  }
}

// 위젯 테스트 코드
void main() {
  testWidgets('버튼 탭 시 카운터가 증가하는지 테스트', (tester) async {
    // ProviderScope로 위젯 감싸기
    await tester.pumpWidget(
      ProviderScope(
        child: MaterialApp(home: CounterPage()),
      ),
    );

    // 초기값 확인
    expect(find.text('0'), findsOneWidget);

    // 버튼 탭
    await tester.tap(find.byType(FloatingActionButton));
    await tester.pump();

    // 증가된 값 확인
    expect(find.text('1'), findsOneWidget);
  });
}

지금까지 김개발 씨는 Provider의 로직만 테스트했습니다. 하지만 사용자가 실제로 경험하는 것은 화면에 보이는 위젯입니다.

버튼을 눌렀을 때 숫자가 바뀌는지, 데이터를 불러올 때 로딩 표시가 나타나는지, 이런 것들이 진짜 중요합니다. 박시니어 씨가 설명합니다.

"위젯 테스트는 통합의 관점에서 바라봐야 해요. Provider 로직과 UI가 제대로 연결되어 있는지 확인하는 거죠." 위젯 테스트를 식당에 비유해볼까요?

주방(Provider)에서 음식을 잘 만드는 것도 중요하지만, 그 음식이 손님 테이블(UI)에 제대로 전달되는지도 확인해야 합니다. 위젯 테스트는 이 전체 과정이 매끄럽게 이뤄지는지 검증합니다.

위의 코드를 살펴보겠습니다. testWidgets는 Flutter에서 위젯 테스트를 작성할 때 사용하는 함수입니다.

tester는 가상의 사용자로, 화면을 탭하거나 스크롤하는 등의 동작을 시뮬레이션합니다. 핵심은 ProviderScope로 테스트할 위젯을 감싸는 것입니다.

실제 앱에서 main.dart에 ProviderScope를 두는 것처럼, 테스트에서도 Provider들이 동작할 수 있는 환경을 만들어줘야 합니다. **tester.pumpWidget()**은 위젯을 렌더링합니다.

**tester.tap()**은 특정 위젯을 탭합니다. **tester.pump()**는 화면을 다시 그리도록 합니다.

상태가 바뀌면 pump()를 호출해야 UI에 반영됩니다. 오버라이드도 사용할 수 있습니다.

ProviderScope에 overrides 파라미터를 전달하면 됩니다. 예를 들어 로딩 상태일 때 로딩 스피너가 제대로 표시되는지 테스트하고 싶다면, FutureProvider를 항상 로딩 상태로 오버라이드하면 됩니다.

비동기 위젯을 테스트할 때는 **tester.pumpAndSettle()**이 유용합니다. 모든 애니메이션과 비동기 작업이 완료될 때까지 기다려줍니다.

단, 무한 애니메이션이 있으면 타임아웃이 발생하니 주의하세요. 실무에서 위젯 테스트는 회귀 버그를 방지하는 데 큰 역할을 합니다.

누군가 Provider 로직을 수정했을 때, 위젯 테스트가 실패하면 UI에 영향이 있다는 것을 바로 알 수 있습니다. 김개발 씨가 물었습니다.

"모든 위젯을 테스트해야 하나요?" 박시니어 씨가 답합니다. "핵심적인 사용자 흐름을 우선적으로 테스트하세요.

로그인, 결제, 핵심 기능 같은 것들이요. 모든 것을 테스트하려다 지치면 안 돼요."

실전 팁

💡 - 위젯에 Key를 부여하면 find.byKey()로 쉽게 찾을 수 있습니다

  • pumpAndSettle()은 비동기 작업 완료를 기다리지만, 무한 애니메이션에 주의하세요
  • 핵심 사용자 흐름을 우선적으로 테스트하세요

5. 통합 테스트 전략

김개발 씨는 단위 테스트와 위젯 테스트를 익혔습니다. 하지만 실제 앱은 여러 화면이 연결되어 있고, 로그인부터 결제까지 이어지는 흐름이 있습니다.

이런 전체 흐름을 테스트하려면 어떻게 해야 할까요? 박시니어 씨가 마지막 비법을 알려줍니다.

"통합 테스트로 전체 시나리오를 검증해요."

통합 테스트는 앱의 전체적인 사용자 여정을 검증하는 테스트입니다. 마치 자동차 출고 전에 실제 도로에서 시운전을 하듯이, 여러 화면과 기능이 연결된 실제 시나리오를 테스트합니다.

Flutter의 integration_test 패키지를 사용하면 실제 디바이스나 에뮬레이터에서 앱을 구동하며 테스트할 수 있습니다.

다음 코드를 살펴봅시다.

// integration_test/app_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:my_app/main.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('로그인부터 장바구니 추가까지 전체 흐름 테스트', (tester) async {
    // 앱 실행
    app.main();
    await tester.pumpAndSettle();

    // 로그인 화면에서 이메일, 비밀번호 입력
    await tester.enterText(find.byKey(Key('email')), 'test@test.com');
    await tester.enterText(find.byKey(Key('password')), 'password123');
    await tester.tap(find.byKey(Key('loginButton')));
    await tester.pumpAndSettle();

    // 상품 목록 화면 확인
    expect(find.text('상품 목록'), findsOneWidget);

    // 첫 번째 상품 장바구니에 추가
    await tester.tap(find.byKey(Key('addToCart_0')));
    await tester.pumpAndSettle();

    // 장바구니 화면으로 이동
    await tester.tap(find.byIcon(Icons.shopping_cart));
    await tester.pumpAndSettle();

    // 장바구니에 상품이 있는지 확인
    expect(find.text('장바구니 (1)'), findsOneWidget);
  });
}

김개발 씨는 문득 걱정이 됐습니다. 단위 테스트와 위젯 테스트를 열심히 작성했지만, 실제로 앱을 실행하면 다르게 동작할 수도 있지 않을까요?

박시니어 씨가 고개를 끄덕입니다. "좋은 질문이에요.

통합 테스트가 바로 그 걱정을 해결해줘요." 통합 테스트를 비유하자면 자동차 시운전과 같습니다. 엔진, 브레이크, 조향 장치를 각각 테스트하는 것도 중요하지만, 실제로 도로에서 달려봐야 모든 부품이 조화롭게 동작하는지 알 수 있습니다.

통합 테스트는 앱 전체를 실제로 구동하며 사용자 시나리오를 검증합니다. 위의 코드를 살펴보겠습니다.

**IntegrationTestWidgetsFlutterBinding.ensureInitialized()**는 통합 테스트 환경을 초기화합니다. **app.main()**으로 실제 앱을 실행합니다.

그 다음부터는 실제 사용자가 앱을 사용하는 것처럼 동작을 시뮬레이션합니다. 텍스트 필드에 입력하고, 버튼을 탭하고, 화면 전환을 확인합니다.

**pumpAndSettle()**은 화면 전환 애니메이션까지 모두 완료될 때까지 기다립니다. 통합 테스트의 강점은 실제 환경과 거의 동일하다는 것입니다.

화면 전환, 네트워크 요청, 데이터베이스 저장까지 모든 것이 실제로 동작합니다. 물론 테스트 환경에서는 Mock 서버를 사용하는 것이 좋습니다.

하지만 통합 테스트에는 단점도 있습니다. 실행 시간이 오래 걸립니다.

한 번의 테스트에 수십 초가 걸릴 수 있습니다. 또한 불안정할 수 있습니다.

네트워크 상태나 디바이스 성능에 따라 결과가 달라질 수 있습니다. 그래서 테스트 피라미드 전략을 따르는 것이 좋습니다.

단위 테스트를 가장 많이 작성하고, 위젯 테스트는 중간 정도, 통합 테스트는 핵심 시나리오만 작성합니다. 비용 대비 효과를 최대화하는 전략입니다.

통합 테스트를 실행하려면 특별한 명령어가 필요합니다. flutter test integration_test를 실행하면 연결된 디바이스나 에뮬레이터에서 테스트가 진행됩니다.

실무에서 통합 테스트는 배포 전 최종 검증에 사용됩니다. CI/CD 파이프라인에서 매 배포 전에 핵심 시나리오를 자동으로 테스트하면, 치명적인 버그가 프로덕션에 나가는 것을 방지할 수 있습니다.

김개발 씨가 물었습니다. "어떤 시나리오를 통합 테스트로 작성해야 하나요?" 박시니어 씨가 답합니다.

"돈이 오가는 흐름, 회원가입과 로그인, 핵심 비즈니스 로직이요. 이게 깨지면 회사가 손해를 보는 기능들을 우선적으로 테스트하세요."

실전 팁

💡 - 통합 테스트는 핵심 비즈니스 흐름에 집중하세요

  • 테스트 피라미드를 따라 단위 테스트를 가장 많이 작성하세요
  • CI/CD 파이프라인에 통합 테스트를 포함해 배포 전 자동 검증하세요

6. 테스트 커버리지 높이기

김개발 씨는 열심히 테스트를 작성했습니다. 그런데 팀장님이 물었습니다.

"커버리지는 얼마나 되나요?" 김개발 씨는 머리를 긁적였습니다. 테스트를 많이 작성한 것 같은데, 정말 중요한 부분을 빠뜨리지는 않았을까요?

박시니어 씨가 커버리지 측정 방법을 알려줍니다.

테스트 커버리지는 코드의 얼마나 많은 부분이 테스트로 검증되었는지 나타내는 지표입니다. 마치 건강검진에서 여러 항목을 체크하듯이, 코드의 각 줄, 분기, 함수가 테스트되었는지 확인합니다.

커버리지를 측정하고 분석하면 테스트가 부족한 영역을 파악하고 개선할 수 있습니다.

다음 코드를 살펴봅시다.

// 커버리지 측정 및 리포트 생성 명령어
// flutter test --coverage

// lcov.info 파일을 HTML 리포트로 변환
// genhtml coverage/lcov.info -o coverage/html

// 테스트되지 않은 분기를 찾는 예시
class PaymentService {
  Future<PaymentResult> processPayment(Payment payment) async {
    // 이 분기가 테스트되었는지 확인 필요
    if (payment.amount <= 0) {
      throw InvalidAmountException();
    }

    if (payment.method == PaymentMethod.card) {
      return _processCardPayment(payment);
    } else if (payment.method == PaymentMethod.bank) {
      return _processBankPayment(payment);
    }

    // 이 분기도 테스트 필요
    throw UnsupportedPaymentMethodException();
  }
}

// 모든 분기를 테스트하는 코드
void main() {
  group('PaymentService', () {
    test('금액이 0 이하면 예외 발생', () {
      final payment = Payment(amount: 0, method: PaymentMethod.card);
      expect(() => service.processPayment(payment),
             throwsA(isA<InvalidAmountException>()));
    });

    test('카드 결제 처리', () async {
      final payment = Payment(amount: 10000, method: PaymentMethod.card);
      final result = await service.processPayment(payment);
      expect(result.success, true);
    });

    test('은행 결제 처리', () async {
      final payment = Payment(amount: 10000, method: PaymentMethod.bank);
      final result = await service.processPayment(payment);
      expect(result.success, true);
    });

    test('지원하지 않는 결제 방식은 예외 발생', () {
      final payment = Payment(amount: 10000, method: PaymentMethod.crypto);
      expect(() => service.processPayment(payment),
             throwsA(isA<UnsupportedPaymentMethodException>()));
    });
  });
}

김개발 씨는 테스트를 열심히 작성했지만 의문이 들었습니다. 과연 중요한 부분을 빠뜨리지 않았을까요?

잘못된 결제 금액을 입력했을 때 제대로 에러가 나는지, 지원하지 않는 결제 방식을 선택했을 때 적절히 처리되는지, 이런 예외 상황들을 모두 테스트했을까요? 박시니어 씨가 말합니다.

"그래서 커버리지를 측정해야 해요. 코드의 어떤 부분이 테스트되었고, 어떤 부분이 빠졌는지 한눈에 볼 수 있거든요." 커버리지를 건강검진에 비유해볼까요?

건강검진에서 혈압, 혈당, 콜레스테롤 등 여러 항목을 체크하듯이, 커버리지는 코드의 각 줄, 각 분기, 각 함수가 테스트로 검증되었는지 체크합니다. 검진 항목을 빠뜨리면 숨은 건강 문제를 놓칠 수 있듯이, 커버리지가 낮으면 숨은 버그를 놓칠 수 있습니다.

Flutter에서 커버리지를 측정하는 방법은 간단합니다. flutter test --coverage 명령을 실행하면 coverage/lcov.info 파일이 생성됩니다.

이 파일에는 각 파일의 어떤 줄이 테스트되었는지 정보가 담겨 있습니다. lcov.info 파일은 사람이 읽기 어렵습니다.

genhtml 도구를 사용하면 보기 좋은 HTML 리포트로 변환할 수 있습니다. 리포트를 열어보면 파일별, 함수별로 커버리지 비율이 표시되고, 테스트되지 않은 줄은 빨간색으로 하이라이트됩니다.

위의 코드 예시를 보겠습니다. PaymentService의 processPayment 메서드에는 여러 분기가 있습니다.

금액이 0 이하인 경우, 카드 결제인 경우, 은행 결제인 경우, 그리고 지원하지 않는 결제 방식인 경우입니다. 이 모든 분기를 테스트하지 않으면 분기 커버리지가 100%가 되지 않습니다.

예를 들어 카드 결제만 테스트하고 은행 결제를 테스트하지 않았다면, 은행 결제에 버그가 있어도 테스트가 통과해버립니다. 하지만 커버리지 100%가 목표가 되어서는 안 됩니다.

박시니어 씨가 경고합니다. "커버리지는 참고 지표일 뿐이에요.

100%라고 해서 버그가 없는 건 아니에요. 의미 없는 테스트로 숫자만 채우는 건 오히려 해로워요." 중요한 것은 핵심 비즈니스 로직의 커버리지입니다.

결제, 주문, 인증 같은 중요한 기능은 높은 커버리지를 유지하고, 단순한 UI 코드는 상대적으로 낮아도 괜찮습니다. 또한 엣지 케이스를 테스트하는 것이 중요합니다.

정상적인 경우만 테스트하면 커버리지는 높아지지만, 예외 상황에서 버그가 발생할 수 있습니다. 빈 배열, null 값, 극단적인 숫자 등을 입력해보세요.

CI/CD 파이프라인에서 커버리지 임계값을 설정하는 것도 좋은 방법입니다. 예를 들어 커버리지가 70% 미만으로 떨어지면 빌드를 실패시키도록 설정할 수 있습니다.

이렇게 하면 팀 전체가 테스트 작성에 신경 쓰게 됩니다. 김개발 씨는 커버리지 리포트를 보며 놀랐습니다.

자신이 작성한 코드 중 몇몇 분기가 빨간색으로 표시되어 있었습니다. "아, 이 부분은 테스트를 깜빡했네요!" 테스트는 미래의 자신과 동료를 위한 안전장치입니다.

커버리지를 높이면서도 의미 있는 테스트를 작성하는 것, 그것이 진정한 테스팅 전략입니다.

실전 팁

💡 - 커버리지 100%보다 핵심 비즈니스 로직의 높은 커버리지가 중요합니다

  • 정상 케이스뿐 아니라 엣지 케이스(빈 값, null, 경계값)도 테스트하세요
  • CI/CD에 커버리지 임계값을 설정해 품질을 유지하세요

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#Flutter#Riverpod#Testing#ProviderContainer#AsyncNotifier#Flutter,State Management

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

함께 보면 좋은 카드 뉴스

Spring Boot 상품 서비스 구축 완벽 가이드

실무 RESTful API 설계부터 테스트, 배포까지 Spring Boot로 상품 서비스를 만드는 전 과정을 다룹니다. JPA 엔티티 설계, OpenAPI 문서화, Docker Compose 배포 전략을 초급 개발자도 쉽게 따라할 수 있도록 스토리텔링으로 풀어냅니다.

단위 테스트와 통합 테스트 완벽 가이드

테스트 코드 작성이 처음이라면 이 가이드로 시작하세요. JUnit 5 기초부터 Mockito, MockMvc, SpringBootTest, Testcontainers까지 실무에서 바로 쓸 수 있는 테스트 기법을 단계별로 배웁니다.

Riverpod 3.0 쇼핑 앱 종합 프로젝트 완벽 가이드

Flutter와 Riverpod 3.0을 활용한 실무 수준의 쇼핑 앱 개발 과정을 단계별로 학습합니다. 상품 목록, 장바구니, 주문, 인증, 검색 기능까지 모든 핵심 기능을 구현하며 상태 관리의 실전 노하우를 익힙니다.

Riverpod 3.0 Retry 자동 재시도 완벽 가이드

Riverpod 3.0에 새로 추가된 Retry 기능을 활용하여 네트워크 오류나 일시적인 실패 상황에서 자동으로 재시도하는 방법을 배웁니다. 초급 개발자도 쉽게 따라할 수 있도록 실무 예제와 함께 설명합니다.

Riverpod 3.0 requireValue로 Provider 결합하기

Riverpod 3.0에 새로 추가된 requireValue를 활용하여 여러 Provider의 데이터를 효율적으로 결합하는 방법을 배웁니다. 비동기 데이터를 마치 동기 데이터처럼 다루는 실전 패턴을 소개합니다.