🤖

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

⚠️

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

이미지 로딩 중...

Riverpod Provider 오버라이드 테스트 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 12. 11. · 17 Views

Riverpod Provider 오버라이드 테스트 완벽 가이드

Riverpod의 overrideWithValue와 overrideWith를 활용한 테스트 작성 방법을 실무 예제로 배워봅니다. Repository 모킹부터 위젯 테스트, 통합 테스트까지 단계별로 익힐 수 있습니다.


목차

  1. overrideWithValue_사용법
  2. overrideWith로_동적_교체
  3. Repository_모킹_예제
  4. 위젯_테스트_설정
  5. 통합_테스트_예제
  6. 테스트_유틸리티_함수

1. overrideWithValue 사용법

어느 날 이지수 씨는 처음으로 Flutter 앱의 테스트 코드를 작성하게 되었습니다. "테스트할 때 실제 API를 호출하면 안 된다는데, 어떻게 해야 하지?" 막막한 마음에 팀장님께 여쭤봤더니, "Provider를 오버라이드하면 돼요"라는 답변을 들었습니다.

overrideWithValue는 Provider의 값을 테스트용 고정값으로 교체하는 방법입니다. 마치 실제 데이터베이스 대신 메모리에 저장된 테스트 데이터를 사용하는 것과 같습니다.

이를 통해 외부 의존성 없이 안정적인 테스트를 작성할 수 있습니다.

다음 코드를 살펴봅시다.

// userProvider를 테스트용 User 객체로 교체합니다
final container = ProviderContainer(
  overrides: [
    // 실제 Provider 대신 고정된 테스트 데이터 사용
    userProvider.overrideWithValue(
      User(id: '123', name: '테스트유저', email: 'test@example.com'),
    ),
  ],
);

// 이제 userProvider를 읽으면 위의 테스트 데이터가 반환됩니다
final user = container.read(userProvider);
print(user.name); // 출력: 테스트유저

// 테스트 종료 시 정리
container.dispose();

이지수 씨는 입사 2개월 차 Flutter 개발자입니다. 오늘 처음으로 테스트 코드를 작성하라는 미션을 받았습니다.

코드를 작성하는 것은 익숙한데, 테스트는 어디서부터 시작해야 할지 막막했습니다. 특히 걱정되는 부분이 있었습니다.

앱에서 사용하는 userProvider는 실제 서버에서 사용자 정보를 가져옵니다. 테스트할 때마다 서버에 요청을 보내면 느리고, 네트워크가 끊기면 테스트가 실패할 수도 있습니다.

팀장 김베테랑 씨가 옆에서 말했습니다. "걱정 마세요.

Riverpod의 오버라이드 기능을 사용하면 됩니다." 그렇다면 Provider 오버라이드란 정확히 무엇일까요? 쉽게 비유하자면, 오버라이드는 마치 영화 촬영 중 실제 건물 대신 세트장을 사용하는 것과 같습니다.

실제 건물을 사용하면 날씨나 시간에 영향을 받지만, 세트장은 언제든지 원하는 환경을 만들 수 있습니다. Provider 오버라이드도 마찬가지로 실제 데이터 소스 대신 테스트용 가짜 데이터를 사용할 수 있게 해줍니다.

Provider 오버라이드가 없던 시절에는 어땠을까요? 개발자들은 테스트용 코드와 실제 코드를 따로 관리해야 했습니다.

테스트 환경을 구분하기 위해 복잡한 설정 파일을 만들고, 환경 변수를 조작하는 등 번거로운 작업이 필요했습니다. 더 큰 문제는 테스트가 외부 환경에 의존하다 보니 불안정했다는 점입니다.

네트워크가 느리면 테스트도 느려지고, 서버가 다운되면 테스트도 실패했습니다. 바로 이런 문제를 해결하기 위해 Provider 오버라이드가 등장했습니다.

오버라이드를 사용하면 외부 의존성을 완전히 제거할 수 있습니다. 또한 원하는 시나리오를 자유롭게 테스트할 수 있습니다.

무엇보다 테스트가 빠르고 안정적이라는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 ProviderContainer를 생성하는 부분이 핵심입니다. 이 컨테이너가 모든 Provider를 관리하는 중앙 저장소 역할을 합니다.

overrides 매개변수에 배열을 전달하는데, 여기에 교체하고 싶은 Provider들을 나열합니다. userProvider.overrideWithValue는 원래의 userProvider를 새로운 값으로 덮어쓴다는 의미입니다.

괄호 안에는 테스트용 User 객체를 직접 생성해서 넣었습니다. 이제 이 컨테이너 안에서 userProvider를 읽으면 실제 서버 데이터가 아니라 우리가 만든 테스트 데이터가 반환됩니다.

**container.read(userProvider)**로 값을 읽어오면 우리가 지정한 테스트유저가 나옵니다. 마지막으로 **container.dispose()**로 정리 작업을 해줍니다.

이것은 마치 식당에서 식사를 마친 후 테이블을 정리하는 것과 같습니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 쇼핑몰 앱을 개발한다고 가정해봅시다. 장바구니 기능을 테스트할 때 실제 상품 데이터베이스에 접근하면 테스트 중에 실수로 데이터를 변경할 수도 있습니다.

하지만 overrideWithValue로 테스트용 상품 목록을 만들어 사용하면 안전하게 모든 시나리오를 검증할 수 있습니다. 카카오, 네이버 같은 대기업에서도 이런 패턴을 적극적으로 사용하고 있습니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 오버라이드를 전역으로 설정하는 것입니다.

한 테스트에서 설정한 오버라이드가 다른 테스트에 영향을 미치면 예상치 못한 버그가 발생할 수 있습니다. 따라서 각 테스트마다 새로운 ProviderContainer를 생성하고, 테스트가 끝나면 반드시 dispose로 정리해야 합니다.

다시 이지수 씨의 이야기로 돌아가 봅시다. 김베테랑 팀장의 설명을 들은 이지수 씨는 눈이 반짝였습니다.

"아, 이렇게 하면 서버 없이도 테스트할 수 있겠네요!" overrideWithValue를 제대로 이해하면 외부 의존성 없이 빠르고 안정적인 테스트 코드를 작성할 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - 각 테스트마다 새로운 ProviderContainer를 생성하여 테스트 간 독립성을 보장하세요

  • dispose()를 잊지 말고 호출하여 메모리 누수를 방지하세요
  • 간단한 고정값 테스트에는 overrideWithValue가 가장 적합합니다

2. overrideWith로 동적 교체

테스트를 작성하다 보니 이지수 씨는 새로운 문제에 부딪혔습니다. "고정된 값이 아니라 상태가 변하는 Provider는 어떻게 테스트하지?" FutureProvider나 StreamProvider처럼 비동기 데이터를 다루는 경우, overrideWithValue만으로는 부족했습니다.

overrideWith는 Provider의 로직 자체를 다른 함수로 교체하는 방법입니다. 마치 자동차 엔진을 통째로 바꾸는 것처럼, Provider의 내부 동작 방식을 완전히 다른 것으로 대체할 수 있습니다.

이를 통해 복잡한 비동기 로직이나 상태 변화를 자유롭게 테스트할 수 있습니다.

다음 코드를 살펴봅시다.

// 원본: 실제 API를 호출하는 FutureProvider
final userDataProvider = FutureProvider<User>((ref) async {
  return await apiService.fetchUser();
});

// 테스트: API 호출 대신 즉시 반환하는 로직으로 교체
final container = ProviderContainer(
  overrides: [
    userDataProvider.overrideWith((ref) async {
      // 여기서 원하는 테스트 시나리오를 구현합니다
      await Future.delayed(Duration(milliseconds: 100)); // 약간의 지연 시뮬레이션
      return User(id: '999', name: '목테스터', email: 'mock@test.com');
    }),
  ],
);

// AsyncValue를 통해 상태 확인
final asyncValue = container.read(userDataProvider);
print(asyncValue.value?.name); // 출력: 목테스터

이지수 씨는 첫 번째 테스트 작성에 성공했습니다. overrideWithValue로 간단한 Provider를 테스트하는 것은 문제없었습니다.

하지만 다음 과제를 받자마자 막막함이 다시 찾아왔습니다. "이번에는 FutureProvider를 테스트해야 하는데..." 이지수 씨가 작성한 userDataProvider는 서버에서 데이터를 비동기로 가져옵니다.

로딩 중일 때, 성공했을 때, 에러가 발생했을 때 각각 다른 UI를 보여줘야 합니다. 이런 다양한 상태를 어떻게 테스트할 수 있을까요?

김베테랑 팀장이 다시 나타났습니다. "이번에는 overrideWith를 사용해보세요.

값만 바꾸는 게 아니라 동작 자체를 바꿀 수 있습니다." 그렇다면 overrideWith는 정확히 무엇일까요? 쉽게 비유하자면, overrideWith는 마치 배우의 대역을 사용하는 것과 같습니다.

overrideWithValue가 소품을 바꾸는 것이라면, overrideWith는 배우 자체를 바꾸는 것입니다. 대역 배우는 원래 배우와 똑같이 행동할 수도 있고, 스턴트처럼 특수한 동작을 수행할 수도 있습니다.

Provider도 마찬가지로 원래 로직 대신 테스트에 필요한 동작을 수행하도록 만들 수 있습니다. overrideWith가 없다면 어떤 문제가 생길까요?

비동기 Provider를 테스트할 때 실제 네트워크 요청을 기다려야 합니다. 테스트가 느려지는 것은 물론이고, 에러 상황을 재현하기도 어렵습니다.

예를 들어 서버가 500 에러를 반환하는 경우를 테스트하려면 실제로 서버에 문제가 생기길 기다려야 할까요? 그것은 불가능합니다.

또한 로딩 상태를 테스트하려면 네트워크 속도를 인위적으로 느리게 만들어야 하는데, 이것도 복잡한 작업입니다. 바로 이런 문제를 해결하기 위해 overrideWith가 필요합니다.

overrideWith를 사용하면 원하는 타이밍에 원하는 결과를 반환할 수 있습니다. 또한 에러 상황을 쉽게 시뮬레이션할 수 있습니다.

무엇보다 실제 로직의 구조를 그대로 유지하면서 내용만 교체할 수 있다는 장점이 있습니다. 위의 코드를 단계별로 살펴보겠습니다.

먼저 원본 userDataProvider는 FutureProvider로 정의되어 있습니다. 이것은 비동기 작업을 수행하고 User 객체를 반환합니다.

실제 환경에서는 apiService.fetchUser()가 서버에 HTTP 요청을 보냅니다. 테스트 코드에서는 userDataProvider.overrideWith를 사용합니다.

괄호 안에 새로운 함수를 전달하는데, 이 함수가 원래 Provider의 로직을 대체합니다. async 키워드가 붙어 있어 비동기 작업을 수행할 수 있습니다.

Future.delayed로 100밀리초의 지연을 추가했습니다. 이것은 실제 네트워크 요청처럼 약간의 시간이 걸리는 상황을 시뮬레이션하기 위함입니다.

테스트에서 로딩 상태를 확인하고 싶다면 이 지연 시간을 조절하면 됩니다. 마지막으로 테스트용 User 객체를 반환합니다.

실제 서버 데이터가 아니라 우리가 원하는 테스트 데이터를 즉시 만들어 사용할 수 있습니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 소셜 미디어 앱을 개발한다고 가정해봅시다. 게시물 목록을 가져오는 postsProvider가 있습니다.

정상 동작 테스트, 네트워크 에러 테스트, 빈 목록 테스트 등 다양한 시나리오가 필요합니다. overrideWith를 사용하면 각 시나리오마다 다른 함수를 제공하여 모든 경우를 쉽게 테스트할 수 있습니다.

토스, 배달의민족 같은 서비스들도 이런 방식으로 수천 개의 테스트를 안정적으로 운영합니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수는 overrideWith 안에서 또 다른 Provider를 읽으려고 하는 것입니다. ref 매개변수를 통해 다른 Provider를 참조할 수는 있지만, 순환 참조가 발생하지 않도록 주의해야 합니다.

또한 overrideWith로 교체한 함수가 원본과 동일한 타입을 반환해야 합니다. FutureProvider<User>를 오버라이드한다면 반드시 User 객체를 반환해야 합니다.

다시 이지수 씨의 이야기로 돌아가 봅시다. 김베테랑 팀장의 설명을 듣고 직접 코드를 작성해본 이지수 씨는 감탄했습니다.

"와, 이제 로딩 상태도 테스트할 수 있겠네요!" overrideWith를 제대로 이해하면 비동기 로직이나 복잡한 상태 변화도 자유자재로 테스트할 수 있습니다. 여러분도 다양한 시나리오를 상상하며 테스트를 작성해 보세요.

실전 팁

💡 - 에러 상황을 테스트하려면 overrideWith 안에서 Exception을 throw하세요

  • 로딩 상태를 테스트하려면 Future.delayed로 지연을 추가하세요
  • 반환 타입을 원본 Provider와 일치시켜야 타입 에러가 발생하지 않습니다

3. Repository 모킹 예제

이지수 씨의 팀은 Clean Architecture를 적용하고 있었습니다. 그런데 테스트를 작성하려니 문제가 생겼습니다.

"Provider가 Repository를 사용하는데, 이것까지 어떻게 모킹하지?" 실제 데이터베이스나 API 없이 Repository 계층을 테스트하는 방법이 필요했습니다.

Repository 모킹은 데이터 계층을 가짜 구현체로 교체하여 테스트하는 기법입니다. 마치 진짜 창고 대신 미니어처 창고 모형을 사용하는 것처럼, 실제 데이터베이스나 API 없이도 Repository의 동작을 검증할 수 있습니다.

Riverpod에서는 Repository Provider를 오버라이드하여 이를 구현합니다.

다음 코드를 살펴봅시다.

// Repository 인터페이스
abstract class UserRepository {
  Future<User> getUser(String id);
  Future<void> updateUser(User user);
}

// 테스트용 가짜 Repository 구현
class MockUserRepository implements UserRepository {
  final Map<String, User> _fakeDatabase = {};

  @override
  Future<User> getUser(String id) async {
    await Future.delayed(Duration(milliseconds: 50)); // DB 접근 시뮬레이션
    return _fakeDatabase[id] ?? User(id: id, name: 'Unknown', email: '');
  }

  @override
  Future<void> updateUser(User user) async {
    _fakeDatabase[user.id] = user; // 메모리에 저장
  }
}

// Repository Provider 정의
final userRepositoryProvider = Provider<UserRepository>((ref) {
  throw UnimplementedError(); // 테스트에서 반드시 오버라이드해야 함
});

// 테스트 코드
final mockRepo = MockUserRepository();
final container = ProviderContainer(
  overrides: [
    userRepositoryProvider.overrideWithValue(mockRepo),
  ],
);

이지수 씨의 프로젝트는 점점 커지고 있었습니다. 처음에는 Provider에서 직접 API를 호출했지만, 이제는 제대로 된 아키텍처를 적용하기로 했습니다.

팀장님이 Clean Architecture를 도입하면서 말했습니다. "앞으로는 Repository 패턴을 사용합니다." Repository는 데이터 소스를 추상화하는 계층입니다.

API든 로컬 데이터베이스든, Repository 인터페이스만 동일하면 Provider는 신경 쓸 필요가 없습니다. 좋은 설계인 것은 알겠는데, 문제는 테스트였습니다.

"Repository를 사용하는 Provider를 테스트하려면 어떻게 해야 하지?" 이지수 씨는 고민에 빠졌습니다. Repository가 실제 API를 호출한다면, Provider 테스트도 결국 네트워크에 의존하게 됩니다.

이것은 앞서 해결하려던 문제가 다시 돌아온 것입니다. 김베테랑 팀장이 화이트보드에 그림을 그리며 설명했습니다.

"Repository도 결국 Provider로 관리하면 됩니다. 그리고 테스트할 때는 가짜 Repository로 오버라이드하는 거죠." 그렇다면 Repository 모킹이란 정확히 무엇일까요?

쉽게 비유하자면, Repository 모킹은 마치 소방 훈련을 할 때 진짜 불 대신 연기 발생기를 사용하는 것과 같습니다. 진짜 불을 피우면 위험하지만, 연기 발생기는 실제 상황을 안전하게 시뮬레이션할 수 있습니다.

Repository도 마찬가지로 진짜 데이터베이스나 API 대신 메모리에 저장하는 가짜 구현체를 사용하면 안전하고 빠르게 테스트할 수 있습니다. Repository 모킹 없이는 어떤 문제가 생길까요?

Provider를 테스트하려면 Repository가 필요하고, Repository를 테스트하려면 데이터베이스가 필요하고, 데이터베이스를 사용하려면 서버를 띄워야 합니다. 이렇게 되면 테스트 하나를 실행하는 데 엄청난 준비 작업이 필요합니다.

더 큰 문제는 테스트 중에 데이터베이스의 데이터가 변경되면 다른 테스트에 영향을 줄 수 있다는 점입니다. 바로 이런 문제를 해결하기 위해 Repository 모킹이 필요합니다.

Mock Repository를 사용하면 외부 의존성을 완전히 제거할 수 있습니다. 또한 원하는 데이터를 자유롭게 설정할 수 있습니다.

무엇보다 테스트가 다른 테스트에 영향을 주지 않습니다. 각 테스트마다 새로운 Mock Repository를 생성하기 때문입니다.

위의 코드를 자세히 살펴보겠습니다. 먼저 UserRepository 인터페이스를 정의했습니다.

Dart에서는 abstract class로 인터페이스를 만듭니다. getUser와 updateUser 메서드는 구현 없이 선언만 되어 있습니다.

이것이 계약입니다. 이 인터페이스를 구현하는 클래스는 반드시 이 두 메서드를 제공해야 합니다.

MockUserRepository는 이 인터페이스를 구현한 가짜 버전입니다. implements UserRepository라고 선언하여 계약을 지키겠다고 명시했습니다.

내부에는 _fakeDatabase라는 Map이 있습니다. 실제 데이터베이스 대신 메모리에 데이터를 저장합니다.

getUser 메서드는 Future.delayed로 약간의 지연을 추가했습니다. 실제 데이터베이스 쿼리처럼 시간이 걸리는 것을 시뮬레이션하기 위함입니다.

**_fakeDatabase[id]**로 Map에서 데이터를 찾고, 없으면 기본값을 반환합니다. updateUser 메서드는 단순히 Map에 데이터를 저장합니다.

실제 데이터베이스라면 SQL INSERT나 UPDATE 쿼리를 실행하겠지만, 테스트에서는 이것만으로 충분합니다. userRepositoryProvider는 Repository를 제공하는 Provider입니다.

중요한 점은 기본 구현에서 UnimplementedError를 던진다는 것입니다. 이것은 의도적인 설계입니다.

실수로 오버라이드를 잊어버리면 명확한 에러 메시지를 받을 수 있습니다. 테스트 코드에서는 MockUserRepository 인스턴스를 만들고, overrideWithValue로 Provider를 교체합니다.

이제 userRepositoryProvider를 사용하는 모든 Provider는 자동으로 Mock Repository를 사용하게 됩니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 은행 앱을 개발한다고 가정해봅시다. 거래 내역을 조회하는 기능을 테스트할 때 실제 데이터베이스를 사용하면 위험합니다.

실수로 실제 거래 데이터가 변경될 수도 있습니다. 하지만 Mock Repository를 사용하면 안전하게 모든 시나리오를 테스트할 수 있습니다.

예금, 출금, 송금 등 다양한 상황을 메모리에서만 시뮬레이션하고, 테스트가 끝나면 깔끔하게 사라집니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수는 Mock이 너무 단순해서 실제 동작과 다른 경우입니다. 예를 들어 실제 데이터베이스는 동시성 문제를 다루지만, 간단한 Map은 그렇지 않습니다.

따라서 Mock을 만들 때는 실제 Repository의 중요한 동작은 최대한 재현해야 합니다. 또한 Mock이 너무 복잡해지면 Mock 자체를 테스트해야 하는 상황이 올 수도 있습니다.

적절한 균형이 중요합니다. 다시 이지수 씨의 이야기로 돌아가 봅시다.

Mock Repository를 작성하고 테스트를 실행해본 이지수 씨는 놀라움을 감추지 못했습니다. "데이터베이스 없이도 완벽하게 테스트되네요!" Repository 모킹을 제대로 이해하면 데이터 계층을 독립적으로 테스트할 수 있고, 아키텍처를 더 깨끗하게 유지할 수 있습니다.

여러분도 프로젝트에 Repository 패턴을 적용해 보세요.

실전 팁

💡 - Mock Repository는 인터페이스를 implements하여 계약을 명확히 하세요

  • 실제 동작의 중요한 특성(비동기, 에러 등)은 Mock에서도 재현하세요
  • 테스트마다 새로운 Mock 인스턴스를 생성하여 독립성을 보장하세요

4. 위젯 테스트 설정

이제 이지수 씨는 Provider 테스트에 자신감이 생겼습니다. 하지만 다음 과제는 더 어려웠습니다.

"위젯 테스트에서는 어떻게 Provider를 오버라이드하지?" 실제 UI가 Provider의 데이터를 제대로 표시하는지 확인해야 했습니다.

위젯 테스트에서 Provider 오버라이드는 ProviderScope를 활용하여 UI 테스트 환경을 구성하는 방법입니다. 마치 무대 세트를 조립하는 것처럼, 위젯이 동작할 환경을 준비하고 원하는 데이터를 주입합니다.

이를 통해 UI가 다양한 상태를 올바르게 렌더링하는지 검증할 수 있습니다.

다음 코드를 살펴봅시다.

// 테스트할 위젯: 사용자 정보를 표시
class UserProfileWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final user = ref.watch(userProvider);
    return Text('이름: ${user.name}, 이메일: ${user.email}');
  }
}

// 위젯 테스트
testWidgets('UserProfileWidget displays user info', (tester) async {
  // ProviderScope로 감싸고 overrides 전달
  await tester.pumpWidget(
    ProviderScope(
      overrides: [
        userProvider.overrideWithValue(
          User(id: '1', name: '홍길동', email: 'hong@test.com'),
        ),
      ],
      child: MaterialApp(
        home: Scaffold(body: UserProfileWidget()),
      ),
    ),
  );

  // UI가 올바르게 렌더링되었는지 확인
  expect(find.text('이름: 홍길동, 이메일: hong@test.com'), findsOneWidget);
});

이지수 씨는 Provider 단위 테스트를 마스터했습니다. Container를 만들고, 오버라이드하고, 값을 읽는 것은 이제 익숙합니다.

하지만 팀장님이 새로운 요구사항을 주었습니다. "이제 위젯 테스트도 작성해보세요." 위젯 테스트는 UI 컴포넌트가 제대로 동작하는지 확인하는 테스트입니다.

버튼을 눌렀을 때 텍스트가 바뀌는지, 로딩 중일 때 스피너가 표시되는지 같은 것들을 검증합니다. 문제는 이 위젯들이 Provider를 사용한다는 점입니다.

이지수 씨는 고민했습니다. "ProviderContainer를 사용할 수 없는데, 어떻게 오버라이드하지?" 위젯 테스트에서는 실제 Flutter 위젯 트리를 만들어야 하는데, 기존에 배운 방법이 통하지 않았습니다.

김베테랑 팀장이 힌트를 주었습니다. "위젯 트리의 최상단에 ProviderScope를 배치하면 됩니다.

그 안에 overrides를 전달하는 거죠." 그렇다면 위젯 테스트에서 Provider 오버라이드란 정확히 무엇일까요? 쉽게 비유하자면, ProviderScope는 마치 영화 세트장의 조명과 음향 장비를 설치하는 것과 같습니다.

배우들이 연기하려면 적절한 조명과 음향이 필요합니다. 위젯들도 마찬가지로 Provider 데이터가 필요하고, ProviderScope가 그 환경을 제공합니다.

overrides는 세트장에서 특정 조명을 다른 것으로 교체하는 것처럼, 특정 Provider의 값을 테스트용으로 바꾸는 것입니다. 위젯 테스트에서 Provider 오버라이드가 없다면 어떻게 될까요?

위젯이 Provider를 읽으려고 하면 에러가 발생합니다. ProviderScope 없이는 Provider 시스템 자체가 동작하지 않기 때문입니다.

설령 ProviderScope를 추가하더라도, 실제 Provider가 실제 API를 호출한다면 위젯 테스트도 느려지고 불안정해집니다. 특정 에러 상황을 테스트하려면 매번 서버를 조작해야 하는 번거로움도 있습니다.

바로 이런 문제를 해결하기 위해 ProviderScope의 overrides가 필요합니다. overrides를 사용하면 위젯이 사용할 데이터를 정확히 제어할 수 있습니다.

또한 다양한 상태를 쉽게 재현할 수 있습니다. 무엇보다 실제 앱 환경과 동일한 구조를 유지하면서 테스트할 수 있다는 장점이 있습니다.

위의 코드를 단계별로 살펴보겠습니다. 먼저 UserProfileWidgetConsumerWidget을 상속합니다.

이것은 Riverpod에서 Provider를 사용할 수 있는 위젯입니다. **ref.watch(userProvider)**로 사용자 데이터를 읽어와 Text 위젯에 표시합니다.

매우 간단한 위젯이지만, 실제 앱에서는 이런 패턴이 수백 번 반복됩니다. 테스트 코드는 testWidgets 함수로 시작합니다.

이것은 Flutter가 제공하는 위젯 테스트 도구입니다. tester 매개변수를 통해 위젯과 상호작용할 수 있습니다.

tester.pumpWidget은 위젯을 화면에 렌더링하는 명령입니다. 여기서 핵심은 ProviderScope로 전체를 감싼다는 점입니다.

ProviderScope가 Provider 시스템을 활성화하고, overrides 매개변수에 테스트용 데이터를 전달합니다. MaterialAppScaffold는 Flutter 위젯이 제대로 동작하기 위한 기본 구조입니다.

실제 앱처럼 완전한 위젯 트리를 만들어야 텍스트나 버튼 같은 요소들이 올바르게 렌더링됩니다. 마지막으로 expectfind.text로 화면에 올바른 텍스트가 표시되었는지 확인합니다.

findsOneWidget은 해당 텍스트가 정확히 하나 발견되어야 한다는 의미입니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 날씨 앱을 개발한다고 가정해봅시다. 날씨 정보를 표시하는 위젯이 있고, 이것이 weatherProvider에서 데이터를 가져옵니다.

위젯 테스트에서는 맑음, 비, 눈 등 다양한 날씨 상황을 overrides로 주입하여 UI가 올바르게 표시되는지 확인합니다. 또한 로딩 중일 때 로딩 스피너가 나오는지, 에러가 발생했을 때 에러 메시지가 표시되는지도 검증할 수 있습니다.

쿠팡이나 마켓컬리 같은 커머스 앱들은 수천 개의 위젯 테스트로 UI 품질을 보장합니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수는 너무 많은 Provider를 한 번에 오버라이드하는 것입니다. 테스트는 가능한 한 단순해야 합니다.

하나의 위젯을 테스트할 때는 그 위젯이 직접 사용하는 Provider만 오버라이드하는 것이 좋습니다. 또한 tester.pump()와 tester.pumpAndSettle()의 차이를 이해해야 합니다.

비동기 동작이 있다면 pumpAndSettle을 사용하여 모든 애니메이션과 비동기 작업이 완료될 때까지 기다려야 합니다. 다시 이지수 씨의 이야기로 돌아가 봅시다.

첫 위젯 테스트를 성공적으로 실행한 이지수 씨는 기뻤습니다. "UI도 자동으로 테스트할 수 있다니!" 위젯 테스트에서 Provider 오버라이드를 제대로 이해하면 UI 컴포넌트를 체계적으로 검증할 수 있고, 회귀 버그를 예방할 수 있습니다.

여러분도 중요한 위젯부터 테스트를 작성해 보세요.

실전 팁

💡 - ProviderScope는 위젯 트리 최상단에 배치하세요

  • MaterialApp과 Scaffold로 완전한 위젯 환경을 만드세요
  • 비동기 동작이 있다면 await tester.pumpAndSettle()을 사용하세요

5. 통합 테스트 예제

이지수 씨는 이제 Provider 테스트와 위젯 테스트를 마스터했습니다. 그런데 팀장님이 마지막 과제를 주었습니다.

"이제 전체 플로우를 테스트해보세요." 로그인부터 데이터 조회, 수정까지 여러 화면을 거치는 시나리오를 어떻게 테스트할까요?

통합 테스트는 여러 위젯과 Provider가 함께 동작하는 전체 시나리오를 검증하는 테스트입니다. 마치 연극의 리허설처럼, 처음부터 끝까지 실제 사용자의 경험을 시뮬레이션합니다.

ProviderScope를 활용하여 전체 앱 환경을 구성하고, 사용자 인터랙션을 재현합니다.

다음 코드를 살펴봅시다.

// 통합 테스트: 로그인 후 프로필 수정 시나리오
testWidgets('Complete user flow: login and update profile', (tester) async {
  final mockAuthRepo = MockAuthRepository();
  final mockUserRepo = MockUserRepository();

  // 전체 앱을 ProviderScope로 감싸고 모든 Repository 오버라이드
  await tester.pumpWidget(
    ProviderScope(
      overrides: [
        authRepositoryProvider.overrideWithValue(mockAuthRepo),
        userRepositoryProvider.overrideWithValue(mockUserRepo),
      ],
      child: MyApp(), // 실제 앱의 진입점
    ),
  );

  // 1단계: 로그인 화면에서 정보 입력
  await tester.enterText(find.byKey(Key('email_field')), 'test@example.com');
  await tester.enterText(find.byKey(Key('password_field')), 'password123');
  await tester.tap(find.byKey(Key('login_button')));
  await tester.pumpAndSettle(); // 화면 전환 대기

  // 2단계: 홈 화면에서 프로필로 이동
  expect(find.text('홈 화면'), findsOneWidget);
  await tester.tap(find.byIcon(Icons.person));
  await tester.pumpAndSettle();

  // 3단계: 프로필 정보 확인 및 수정
  expect(find.text('test@example.com'), findsOneWidget);
  await tester.tap(find.byKey(Key('edit_button')));
  await tester.enterText(find.byKey(Key('name_field')), '새이름');
  await tester.tap(find.byKey(Key('save_button')));
  await tester.pumpAndSettle();

  // 4단계: 변경사항 확인
  expect(find.text('새이름'), findsOneWidget);
  expect(mockUserRepo.updateCallCount, 1); // Repository 호출 검증
});

이지수 씨는 지난 몇 주 동안 정말 많은 것을 배웠습니다. Provider 단위 테스트, 위젯 테스트까지 작성할 수 있게 되었습니다.

하지만 마음 한구석이 불안했습니다. "각각의 부품은 잘 동작하는데, 조립했을 때도 잘 동작할까?" 김베테랑 팀장이 이지수 씨의 마음을 읽었는지 말했습니다.

"이제 통합 테스트를 작성할 때가 됐네요. 실제 사용자처럼 앱을 사용해보는 테스트입니다." 이지수 씨는 궁금했습니다.

"위젯 테스트와 뭐가 다른가요?" 그렇다면 통합 테스트란 정확히 무엇일까요? 쉽게 비유하자면, 단위 테스트는 자동차 부품 하나하나를 검사하는 것이고, 위젯 테스트는 대시보드나 핸들 같은 하위 시스템을 검사하는 것입니다.

반면 통합 테스트는 완성된 자동차를 실제로 도로에서 운전해보는 것과 같습니다. 시동을 걸고, 기어를 바꾸고, 브레이크를 밟고, 목적지까지 가는 전체 과정을 검증합니다.

앱도 마찬가지로 로그인부터 시작해서 여러 화면을 거쳐 최종 목표를 달성하는 과정을 테스트합니다. 통합 테스트 없이는 어떤 문제가 생길까요?

각 부품은 완벽하게 동작하는데, 조합했을 때 예상치 못한 버그가 발생할 수 있습니다. 예를 들어 로그인은 잘 되는데, 로그인 후 다음 화면으로 넘어갈 때 인증 토큰이 제대로 전달되지 않는다면 어떻게 될까요?

단위 테스트만으로는 이런 문제를 발견할 수 없습니다. 또한 실제 사용자는 하나의 기능만 사용하지 않습니다.

여러 기능을 연속으로 사용하는데, 그 과정에서 상태 관리가 꼬이는 경우가 많습니다. 바로 이런 문제를 발견하기 위해 통합 테스트가 필요합니다.

통합 테스트를 작성하면 실제 사용자 시나리오를 검증할 수 있습니다. 또한 여러 컴포넌트 간의 상호작용을 확인할 수 있습니다.

무엇보다 회귀 버그를 조기에 발견할 수 있다는 큰 장점이 있습니다. 새로운 기능을 추가했는데 기존 기능이 망가졌다면, 통합 테스트가 즉시 알려줍니다.

위의 코드를 자세히 살펴보겠습니다. 먼저 MockAuthRepositoryMockUserRepository 두 개의 Mock 객체를 준비했습니다.

통합 테스트에서는 여러 Repository가 필요하기 때문입니다. 각 Repository는 인증과 사용자 데이터를 담당합니다.

tester.pumpWidget에 **MyApp()**을 전달했습니다. 이것이 통합 테스트의 핵심입니다.

실제 앱의 진입점을 그대로 사용합니다. ProviderScope의 overrides에는 필요한 모든 Repository를 한꺼번에 교체합니다.

첫 번째 단계는 로그인입니다. tester.enterText로 이메일과 비밀번호 입력 필드에 텍스트를 입력합니다.

find.byKey를 사용하여 특정 위젯을 찾는데, 이를 위해 실제 앱 코드에서 각 입력 필드에 Key를 설정해야 합니다. tester.tap으로 로그인 버튼을 누르고, pumpAndSettle로 화면 전환이 완료될 때까지 기다립니다.

두 번째 단계는 홈 화면 확인과 네비게이션입니다. expect로 홈 화면이 제대로 표시되었는지 확인합니다.

그다음 프로필 아이콘을 탭하여 프로필 화면으로 이동합니다. 실제 사용자의 행동을 그대로 재현하는 것입니다.

세 번째 단계는 프로필 수정입니다. 현재 이메일이 올바르게 표시되는지 확인하고, 편집 버튼을 누르고, 이름을 변경하고, 저장 버튼을 누릅니다.

여러 단계가 연속으로 진행됩니다. 네 번째 단계는 결과 검증입니다.

변경한 이름이 화면에 표시되는지 확인하고, 더 나아가 mockUserRepo.updateCallCount로 Repository의 updateUser 메서드가 실제로 호출되었는지도 확인합니다. UI뿐만 아니라 내부 로직도 검증하는 것입니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 전자상거래 앱을 개발한다고 가정해봅시다.

중요한 시나리오는 상품 검색 → 상세 정보 확인 → 장바구니 추가 → 주문 → 결제까지의 전체 구매 플로우입니다. 통합 테스트로 이 플로우를 자동화하면, 새로운 기능을 추가하거나 기존 코드를 리팩토링할 때도 안심할 수 있습니다.

테스트를 실행하기만 하면 전체 플로우가 여전히 정상 동작하는지 즉시 확인할 수 있습니다. 무신사, 지그재그 같은 패션 커머스들은 수백 개의 통합 테스트로 핵심 구매 플로우를 보호합니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수는 통합 테스트를 너무 많이 작성하는 것입니다.

통합 테스트는 단위 테스트보다 느리고 유지보수하기 어렵습니다. 따라서 핵심 사용자 플로우만 통합 테스트로 작성하고, 나머지는 단위 테스트와 위젯 테스트로 커버하는 것이 좋습니다.

또한 Key를 적절히 사용해야 합니다. find.byType이나 find.text는 같은 타입이나 텍스트가 여러 개 있으면 헷갈릴 수 있습니다.

테스트에서 사용할 위젯에는 고유한 Key를 부여하세요. 다시 이지수 씨의 이야기로 돌아가 봅시다.

첫 통합 테스트를 완성하고 실행한 이지수 씨는 감동했습니다. "마치 유령 사용자가 앱을 조작하는 것 같아요!" 통합 테스트를 제대로 이해하면 앱의 완성도를 크게 높일 수 있고, 자신감 있게 배포할 수 있습니다.

여러분도 가장 중요한 사용자 플로우부터 통합 테스트를 작성해 보세요.

실전 팁

💡 - 핵심 사용자 플로우만 통합 테스트로 작성하세요 (시간과 비용 고려)

  • 테스트용 위젯에는 고유한 Key를 부여하여 정확히 찾을 수 있게 하세요
  • pumpAndSettle()로 비동기 작업과 애니메이션이 완료될 때까지 기다리세요

6. 테스트 유틸리티 함수

이지수 씨는 이제 다양한 테스트를 작성할 수 있게 되었습니다. 하지만 테스트가 많아지면서 새로운 문제가 생겼습니다.

"같은 설정 코드를 계속 반복해서 작성해야 하네..." 매번 ProviderScope를 만들고 overrides를 설정하는 것이 지루했습니다.

테스트 유틸리티 함수는 반복되는 테스트 설정 코드를 재사용 가능한 함수로 추출한 것입니다. 마치 레고 블록을 미리 조립해두는 것처럼, 자주 사용하는 테스트 환경을 함수로 만들어 놓으면 테스트 작성이 훨씬 빠르고 간편해집니다.

다음 코드를 살펴봅시다.

// 테스트 유틸리티 파일: test/helpers/provider_test_helper.dart

/// 테스트용 ProviderContainer를 생성하는 헬퍼 함수
ProviderContainer createTestContainer({
  List<Override> additionalOverrides = const [],
}) {
  // 기본 Mock Repository들을 자동으로 설정
  final mockAuthRepo = MockAuthRepository();
  final mockUserRepo = MockUserRepository();

  return ProviderContainer(
    overrides: [
      authRepositoryProvider.overrideWithValue(mockAuthRepo),
      userRepositoryProvider.overrideWithValue(mockUserRepo),
      ...additionalOverrides, // 추가 오버라이드 병합
    ],
  );
}

/// 위젯 테스트용 헬퍼 함수
Widget createTestApp({
  required Widget child,
  List<Override> overrides = const [],
}) {
  return ProviderScope(
    overrides: overrides,
    child: MaterialApp(
      home: Scaffold(body: child),
    ),
  );
}

/// 사용 예제: 테스트 코드가 훨씬 간결해집니다
testWidgets('User profile test with helper', (tester) async {
  await tester.pumpWidget(
    createTestApp(
      child: UserProfileWidget(),
      overrides: [
        userProvider.overrideWithValue(
          User(id: '1', name: '김철수', email: 'kim@test.com'),
        ),
      ],
    ),
  );

  expect(find.text('김철수'), findsOneWidget);
});

이지수 씨는 지난 한 달 동안 수십 개의 테스트를 작성했습니다. Provider 테스트, 위젯 테스트, 통합 테스트까지 다양한 테스트를 마스터했습니다.

하지만 코드를 돌아보니 뭔가 불편했습니다. "어?

이 코드, 저번에도 똑같이 작성했는데..." 매번 ProviderScope를 만들고, MaterialApp으로 감싸고, Mock Repository를 생성하고, overrides를 설정하는 코드가 반복되었습니다. 테스트 100개를 작성하면 이 설정 코드도 100번 반복됩니다.

김베테랑 팀장이 코드 리뷰를 하다가 말했습니다. "반복되는 코드는 함수로 추출하는 게 좋습니다.

테스트 코드도 마찬가지예요." 그렇다면 테스트 유틸리티 함수란 정확히 무엇일까요? 쉽게 비유하자면, 테스트 유틸리티 함수는 마치 요리할 때 미리 재료를 손질해두는 것과 같습니다.

매번 요리할 때마다 양파를 깎고 마늘을 다지는 대신, 미리 손질해둔 재료를 사용하면 훨씬 빠르게 요리할 수 있습니다. 테스트도 마찬가지로 자주 사용하는 설정을 함수로 만들어두면, 새로운 테스트를 작성할 때 간단히 호출하기만 하면 됩니다.

테스트 유틸리티 함수가 없다면 어떤 문제가 생길까요? 첫 번째 문제는 코드 중복입니다.

같은 설정 코드가 수십 번 반복되면 코드베이스가 불필요하게 커집니다. 두 번째 문제는 유지보수의 어려움입니다.

예를 들어 새로운 Repository를 추가하면 모든 테스트의 overrides를 수정해야 합니다. 세 번째 문제는 실수 가능성입니다.

어떤 테스트에서는 특정 Mock을 설정했는데 다른 테스트에서는 깜빡 잊어버릴 수 있습니다. 바로 이런 문제를 해결하기 위해 테스트 유틸리티 함수가 필요합니다.

유틸리티 함수를 사용하면 코드 중복을 제거할 수 있습니다. 또한 테스트 설정이 일관성을 유지합니다.

무엇보다 새로운 테스트를 빠르게 작성할 수 있다는 큰 장점이 있습니다. 팀원들도 같은 유틸리티 함수를 사용하면 테스트 스타일이 통일됩니다.

위의 코드를 자세히 살펴보겠습니다. 먼저 createTestContainer 함수를 만들었습니다.

이 함수는 테스트용 ProviderContainer를 생성하는 역할을 합니다. additionalOverrides라는 선택적 매개변수를 받는데, 이것은 각 테스트에서 추가로 오버라이드하고 싶은 Provider를 전달할 수 있게 합니다.

함수 내부에서는 기본적으로 필요한 Mock Repository들을 자동으로 생성합니다. authRepositoryProvider와 userRepositoryProvider는 거의 모든 테스트에서 필요하므로 기본으로 설정합니다.

그다음 스프레드 연산자로 추가 오버라이드를 병합합니다. createTestApp 함수는 위젯 테스트에 특화된 헬퍼입니다.

위젯 테스트에서는 MaterialApp과 Scaffold로 감싸는 작업이 필수인데, 이것을 자동화했습니다. child 매개변수로 테스트할 위젯을 받고, overrides 매개변수로 Provider 오버라이드를 받습니다.

사용 예제를 보면 테스트 코드가 얼마나 간결해졌는지 알 수 있습니다. 원래는 ProviderScope를 만들고 MaterialApp을 설정하는 등 10줄 이상의 코드가 필요했는데, 이제는 createTestApp 함수 하나로 해결됩니다.

테스트의 본질인 "무엇을 검증할 것인가"에 집중할 수 있게 되었습니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 핀테크 앱을 개발한다고 가정해봅시다. 거래 기능을 테스트할 때마다 사용자 인증, 계좌 정보, 거래 내역 등 여러 Repository를 설정해야 합니다.

createTestContainerWithAuth처럼 특정 시나리오에 맞는 유틸리티 함수를 만들 수 있습니다. 로그인된 상태, 로그아웃된 상태, 권한이 부족한 상태 등 다양한 설정을 함수로 만들어두면 테스트 작성 속도가 10배 빨라집니다.

토스, 뱅크샐러드 같은 핀테크 기업들은 수천 개의 테스트를 관리하는데, 유틸리티 함수 없이는 불가능합니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수는 유틸리티 함수를 너무 복잡하게 만드는 것입니다. 모든 경우를 커버하려다 보면 함수가 수십 개의 매개변수를 받게 되고, 결국 사용하기 어려워집니다.

따라서 간단하고 명확한 목적을 가진 유틸리티 함수를 여러 개 만드는 것이 좋습니다. 또한 테스트 파일 구조도 중요합니다.

test/helpers/ 폴더에 유틸리티 함수들을 모아두면 팀원들이 쉽게 찾아 사용할 수 있습니다. 또 다른 유용한 패턴은 빌더 패턴입니다.

createTestContainer().withMockAuth().withMockUser()처럼 체이닝으로 설정을 추가하는 방식도 고려해볼 만합니다. 이렇게 하면 필요한 설정만 선택적으로 추가할 수 있어 유연성이 높아집니다.

다시 이지수 씨의 이야기로 돌아가 봅시다. 테스트 유틸리티 함수를 작성하고 기존 테스트를 리팩토링한 이지수 씨는 뿌듯했습니다.

"코드가 절반으로 줄었어요! 그리고 훨씬 읽기 쉬워졌네요." 김베테랑 팀장이 고개를 끄덕였습니다.

"좋습니다. 이제 진짜 개발자다운 테스트를 작성하고 있네요." 테스트 유틸리티 함수를 제대로 활용하면 테스트 작성 속도가 빨라지고, 코드 품질도 향상됩니다.

여러분도 반복되는 코드를 발견하면 즉시 함수로 추출하는 습관을 들이세요. 그것이 프로 개발자로 가는 지름길입니다.

실전 팁

💡 - 반복되는 테스트 설정은 즉시 함수로 추출하세요

  • test/helpers/ 폴더에 유틸리티 함수를 모아두면 관리가 쉽습니다
  • 유틸리티 함수는 간단하고 명확한 목적을 가져야 합니다 (너무 복잡하게 만들지 마세요)

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

#Flutter#Riverpod#Testing#ProviderOverride#MockRepository#Flutter,Riverpod

댓글 (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의 데이터를 효율적으로 결합하는 방법을 배웁니다. 비동기 데이터를 마치 동기 데이터처럼 다루는 실전 패턴을 소개합니다.