Flutter 튜토리얼 42편: 의존성 주입과 레이어 테스팅

요약#

핵심 요지#

  • 의존성 주입은 객체 생성 문제를 해결하여 테스트와 유지보수를 쉽게 만듭니다.
  • Provider 패키지를 사용하여 앱 루트에서 서비스와 리포지토리를 제공합니다.
  • 각 레이어는 직접적인 의존성만 모킹하여 독립적으로 테스트합니다.
  • Fake 구현체를 만들어 Unit 테스트와 Widget 테스트에서 재사용합니다.

문서가 설명하는 범위#

Flutter 공식 문서의 Dependency injectionTesting을 기반으로 의존성 주입 패턴 구현 방법과 MVVM 아키텍처에서 각 레이어를 테스트하는 전략을 설명합니다.

참고 자료#


의존성 주입 기초#

Why - 왜 의존성 주입이 필요한가요?#

앱의 컴포넌트들은 서로 의존합니다. ViewModel은 Repository가 필요하고, Repository는 Service가 필요합니다. 이때 각 컴포넌트가 자신의 의존성을 직접 생성하면 문제가 생깁니다.

// 나쁜 예: 내부에서 의존성 생성
class HomeViewModel extends ChangeNotifier {
// 테스트 시 ApiClient를 교체할 수 없음
final _repository = BookingRepository(ApiClient());
}

의존성 주입(Dependency Injection)1은 컴포넌트가 의존성을 스스로 생성하는 대신 외부에서 주입받는 패턴입니다. 이렇게 하면 테스트 시 Mock 객체를 주입할 수 있고, 구현체를 쉽게 교체할 수 있습니다.

What - 의존성 주입이란 무엇인가요?#

의존성 주입은 객체 생성의 책임을 분리하는 설계 패턴입니다. 의존성을 생성자를 통해 전달받으므로 컴포넌트 간 결합도가 낮아집니다.

graph TD A[앱 루트] -->|주입| B[Repository] A -->|주입| C[Service] B -->|사용| D[ViewModel] D -->|사용| E[View] style A fill:#e1f5fe style B fill:#fff3e0 style C fill:#fff3e0 style D fill:#e8f5e9 style E fill:#fce4ec

레이어 간 통신 규칙은 다음과 같습니다.

컴포넌트규칙
View하나의 ViewModel만 알고, 다른 레이어는 모름
ViewModel정확히 하나의 View에 속하고, 하나 이상의 Repository를 주입받음
Repository여러 Service를 주입받고, ViewModel을 알지 못함
Service여러 Repository에서 사용되고, 소비자를 알지 못함

How - 생성자 주입 구현하기#

가장 기본적인 의존성 주입은 생성자를 통해 이루어집니다.

// Repository가 Service를 주입받음
class BookingRepository {
BookingRepository({required ApiClient apiClient})
: _apiClient = apiClient;
final ApiClient _apiClient;
Future<List<Booking>> getBookings() async {
final response = await _apiClient.get('/bookings');
return response.map((e) => Booking.fromJson(e)).toList();
}
}
// ViewModel이 Repository를 주입받음
class HomeViewModel extends ChangeNotifier {
HomeViewModel({required BookingRepository bookingRepository})
: _bookingRepository = bookingRepository;
final BookingRepository _bookingRepository;
List<Booking> _bookings = [];
List<Booking> get bookings => _bookings;
Future<void> loadBookings() async {
_bookings = await _bookingRepository.getBookings();
notifyListeners();
}
}

주입받은 의존성을 private으로 유지하면 외부에서 직접 접근할 수 없습니다. 이렇게 하면 View가 Repository를 우회하여 호출하는 것을 방지합니다.

Watch out - 주의사항#

  • 의존성을 전역 변수나 싱글톤으로 만들지 마세요.
  • 생성자를 통해 명시적으로 주입하면 코드 흐름이 명확해집니다.
  • private 필드로 선언하여 레이어 간 경계를 유지하세요.

Provider를 사용한 의존성 주입#

Why - 왜 Provider를 사용하나요?#

생성자 주입만으로도 의존성 주입이 가능하지만, 앱이 커지면 관리가 어려워집니다. Provider2는 Flutter 팀이 권장하는 의존성 주입 솔루션으로, 위젯 트리를 통해 의존성을 제공합니다.

What - Provider의 역할은 무엇인가요?#

Provider는 두 가지 주요 역할을 합니다.

  1. 의존성 등록: 앱 루트에서 Service와 Repository를 생성하고 등록합니다.
  2. 의존성 조회: 위젯 트리 어디서든 context.read()로 의존성을 가져옵니다.
graph TB subgraph "앱 루트 (MultiProvider)" S1[AuthApiClient] S2[ApiClient] S3[SharedPreferences] R1[AuthRepository] R2[BookingRepository] end subgraph "라우터" VM1[LoginViewModel] VM2[HomeViewModel] end subgraph "화면" V1[LoginScreen] V2[HomeScreen] end S1 --> R1 S2 --> R1 S3 --> R1 S2 --> R2 R1 --> VM1 R2 --> VM2 VM1 --> V1 VM2 --> V2

How - Provider 구현하기#

1단계: 앱 루트에서 Provider 설정

main.dart
void main() {
runApp(
MultiProvider(
providers: [
// Service 등록
Provider(create: (context) => AuthApiClient()),
Provider(create: (context) => ApiClient()),
Provider(create: (context) => SharedPreferencesService()),
// Repository 등록 (Service를 주입받음)
ChangeNotifierProvider(
create: (context) => AuthRepository(
authApiClient: context.read(),
apiClient: context.read(),
sharedPreferencesService: context.read(),
) as AuthRepository,
),
Provider(
create: (context) => BookingRepository(
apiClient: context.read(),
) as BookingRepository,
),
],
child: const MainApp(),
),
);
}

2단계: 라우터에서 ViewModel 생성

router.dart
GoRouter router(AuthRepository authRepository) => GoRouter(
routes: [
GoRoute(
path: '/login',
builder: (context, state) {
return LoginScreen(
viewModel: LoginViewModel(
authRepository: context.read(),
),
);
},
),
GoRoute(
path: '/home',
builder: (context, state) {
return HomeScreen(
viewModel: HomeViewModel(
bookingRepository: context.read(),
userRepository: context.read(),
),
);
},
),
],
);

3단계: View에서 ViewModel 사용

home_screen.dart
class HomeScreen extends StatelessWidget {
final HomeViewModel viewModel;
const HomeScreen({required this.viewModel, super.key});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: viewModel,
builder: (context, _) {
return ListView.builder(
itemCount: viewModel.bookings.length,
itemBuilder: (context, index) {
final booking = viewModel.bookings[index];
return ListTile(title: Text(booking.destination));
},
);
},
);
}
}

Watch out - 주의사항#

  • context.read()는 위젯 트리에서 가장 가까운 Provider를 찾습니다.
  • Repository는 추상 클래스로 등록하면 테스트 시 다른 구현체로 교체할 수 있습니다.
  • ViewModel은 라우터에서 생성하여 화면에 직접 전달하세요.

UI 레이어 테스팅#

Why - 왜 UI 레이어를 테스트하나요?#

UI 레이어는 사용자와 직접 상호작용합니다. ViewModel의 로직이 올바른지, View가 상태를 정확히 표시하는지 확인해야 합니다. 잘 설계된 아키텍처는 각 컴포넌트의 입력과 출력이 명확하여 테스트하기 쉽습니다.

What - UI 레이어 테스트의 구성#

UI 레이어 테스트는 두 가지로 나뉩니다.

테스트 종류대상특징
ViewModel Unit 테스트비즈니스 로직Flutter 의존성 없음, 빠른 실행
View Widget 테스트UI 렌더링사용자 상호작용 시뮬레이션

How - ViewModel 테스트하기#

ViewModel 테스트는 Flutter 프레임워크 없이 순수 Dart로 작성합니다. Repository만 Fake로 교체하면 됩니다.

fake_booking_repository.dart
class FakeBookingRepository implements BookingRepository {
List<Booking> bookings = [];
@override
Future<Result<void>> createBooking(Booking booking) async {
bookings.add(booking);
return Result.ok(null);
}
@override
Future<Result<List<Booking>>> getBookings() async {
return Result.ok(bookings);
}
}
// home_viewmodel_test.dart
void main() {
group('HomeViewModel 테스트', () {
late FakeBookingRepository fakeRepository;
late HomeViewModel viewModel;
setUp(() {
fakeRepository = FakeBookingRepository();
viewModel = HomeViewModel(bookingRepository: fakeRepository);
});
test('예약 목록을 불러온다', () async {
// Given: 예약 데이터가 있을 때
final booking = Booking(id: '1', destination: '서울');
await fakeRepository.createBooking(booking);
// When: 예약 목록을 로드하면
await viewModel.loadBookings();
// Then: 예약이 표시된다
expect(viewModel.bookings, isNotEmpty);
expect(viewModel.bookings.first.destination, equals('서울'));
});
test('예약 생성 시 목록이 업데이트된다', () async {
// Given: 빈 예약 목록
expect(viewModel.bookings, isEmpty);
// When: 새 예약을 생성하면
await viewModel.createBooking(
Booking(id: '2', destination: '부산'),
);
// Then: 목록에 추가된다
expect(viewModel.bookings.length, equals(1));
});
});
}

How - View 테스트하기#

Widget 테스트에서는 ViewModel용 Fake와 라우터용 Mock이 필요합니다.

home_screen_test.dart
void main() {
group('HomeScreen 테스트', () {
late HomeViewModel viewModel;
late MockGoRouter goRouter;
late FakeBookingRepository bookingRepository;
setUp(() {
bookingRepository = FakeBookingRepository()
..createBooking(Booking(id: '1', destination: '서울'));
viewModel = HomeViewModel(bookingRepository: bookingRepository);
goRouter = MockGoRouter();
when(() => goRouter.push(any())).thenAnswer((_) => Future.value(null));
});
testWidgets('예약 목록을 표시한다', (tester) async {
// Given: 예약 데이터가 로드된 ViewModel
await viewModel.loadBookings();
// When: 화면을 렌더링하면
await tester.pumpWidget(
MaterialApp(
home: HomeScreen(viewModel: viewModel),
),
);
// Then: 예약 정보가 표시된다
expect(find.text('서울'), findsOneWidget);
});
testWidgets('예약 탭 시 상세 화면으로 이동한다', (tester) async {
await viewModel.loadBookings();
await tester.pumpWidget(
MaterialApp(
home: InheritedGoRouter(
goRouter: goRouter,
child: HomeScreen(viewModel: viewModel),
),
),
);
// When: 예약 아이템을 탭하면
await tester.tap(find.text('서울'));
await tester.pumpAndSettle();
// Then: 상세 화면으로 이동한다
verify(() => goRouter.push('/booking/1')).called(1);
});
});
}

Watch out - 주의사항#

  • ViewModel 테스트는 Flutter 의존성이 없어야 빠르게 실행됩니다.
  • Widget 테스트에서는 pumpAndSettle()로 애니메이션이 완료될 때까지 기다리세요.
  • Mock 라이브러리로는 mocktail을 사용합니다.

Data 레이어 테스팅#

Why - 왜 Data 레이어를 테스트하나요?#

Data 레이어는 외부 API, 데이터베이스와 통신합니다. 이 레이어의 버그는 앱 전체에 영향을 미치므로 철저히 테스트해야 합니다.

What - Data 레이어 테스트의 특징#

Repository 테스트에서는 Service를 Fake로 교체합니다. 실제 API 호출 없이 다양한 시나리오를 테스트할 수 있습니다.

graph LR A[Repository 테스트] --> B[Fake Service] B --> C[성공 응답] B --> D[에러 응답] B --> E[네트워크 오류]

How - Repository 테스트하기#

fake_api_client.dart
class FakeApiClient implements ApiClient {
final Map<String, dynamic> _mockResponses = {};
Exception? _nextError;
void setResponse(String path, dynamic response) {
_mockResponses[path] = response;
}
void setError(Exception error) {
_nextError = error;
}
@override
Future<dynamic> get(String path) async {
if (_nextError != null) {
final error = _nextError;
_nextError = null;
throw error!;
}
return _mockResponses[path];
}
}
// booking_repository_test.dart
void main() {
group('BookingRepository 테스트', () {
late BookingRepository repository;
late FakeApiClient fakeApiClient;
setUp(() {
fakeApiClient = FakeApiClient();
repository = BookingRepository(apiClient: fakeApiClient);
});
test('예약을 성공적으로 가져온다', () async {
// Given: API가 예약 데이터를 반환
fakeApiClient.setResponse('/bookings', [
{'id': '1', 'destination': '서울'},
{'id': '2', 'destination': '부산'},
]);
// When: 예약 목록을 요청하면
final result = await repository.getBookings();
// Then: 예약 목록을 반환한다
expect(result.isOk, isTrue);
expect(result.value.length, equals(2));
});
test('API 오류 시 에러를 반환한다', () async {
// Given: API가 오류를 발생시킴
fakeApiClient.setError(NetworkException('연결 실패'));
// When: 예약 목록을 요청하면
final result = await repository.getBookings();
// Then: 에러 결과를 반환한다
expect(result.isError, isTrue);
expect(result.error, isA<NetworkException>());
});
test('특정 예약을 조회한다', () async {
// Given: 특정 예약 데이터
fakeApiClient.setResponse('/bookings/1', {
'id': '1',
'destination': '서울',
'date': '2025-01-15',
});
// When: 예약을 조회하면
final result = await repository.getBooking('1');
// Then: 해당 예약을 반환한다
expect(result.isOk, isTrue);
expect(result.value.id, equals('1'));
});
});
}

Watch out - 주의사항#

  • Service는 실제 외부 통신을 담당하므로 통합 테스트에서 검증하세요.
  • Repository 테스트에서는 성공, 실패, 에러 케이스를 모두 테스트하세요.
  • Fake 구현체는 최소한의 로직만 포함해야 합니다.

테스트 헬퍼 함수 만들기#

Why - 왜 테스트 헬퍼가 필요한가요?#

Widget 테스트에서는 반복되는 설정 코드가 많습니다. MaterialApp 래핑, 테마 적용, 라우터 설정 등을 매번 작성하면 코드가 길어집니다.

What - 테스트 헬퍼의 역할#

테스트 헬퍼 함수는 공통 설정을 추상화하여 테스트 코드를 간결하게 만듭니다.

How - 테스트 앱 헬퍼 구현#

test_helpers.dart
Future<void> testApp(
WidgetTester tester,
Widget body, {
GoRouter? goRouter,
ThemeData? theme,
}) async {
// 테스트 환경 설정
tester.view.devicePixelRatio = 1.0;
await tester.binding.setSurfaceSize(const Size(1200, 800));
// 네트워크 이미지 모킹
await mockNetworkImages(() async {
await tester.pumpWidget(
MaterialApp(
// 로컬라이제이션 설정
localizationsDelegates: const [
GlobalWidgetsLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
AppLocalizationDelegate(),
],
// 테마 적용
theme: theme ?? AppTheme.lightTheme,
home: InheritedGoRouter(
goRouter: goRouter ?? MockGoRouter(),
child: Scaffold(body: body),
),
),
);
});
}
// 사용 예시
testWidgets('홈 화면 테스트', (tester) async {
final viewModel = HomeViewModel(
bookingRepository: FakeBookingRepository(),
);
await testApp(
tester,
HomeScreen(viewModel: viewModel),
goRouter: MockGoRouter(),
);
expect(find.byType(ListView), findsOneWidget);
});

Watch out - 주의사항#

  • 테스트 헬퍼는 프로젝트 전체에서 재사용할 수 있도록 설계하세요.
  • 기본값을 제공하되, 필요시 오버라이드할 수 있게 만드세요.
  • 네트워크 이미지는 mockNetworkImages로 감싸서 테스트 실패를 방지하세요.

테스트 전략 정리#

레이어별 테스트 책임#

graph TB subgraph "UI Layer" V[View Widget 테스트] VM[ViewModel Unit 테스트] end subgraph "Data Layer" R[Repository Unit 테스트] end subgraph "Fake 구현체" FR[Fake Repository] FS[Fake Service] end V --> FR VM --> FR R --> FS
테스트 대상모킹 대상테스트 종류
ViewModelRepositoryUnit 테스트
ViewViewModel, RouterWidget 테스트
RepositoryServiceUnit 테스트

좋은 테스트 작성 원칙#

  1. 입력과 출력에 집중: 구현 세부사항이 아닌 동작을 테스트하세요.
  2. Fake 재사용: ViewModel 테스트용 Fake를 Widget 테스트에서도 활용하세요.
  3. 모듈화된 함수: 작고 테스트하기 쉬운 함수를 작성하세요.
  4. 환경 분리: 추상 인터페이스로 개발/스테이징/프로덕션 환경을 분리하세요.

마무리#

의존성 주입과 레이어 테스팅의 핵심을 정리하면 다음과 같습니다.

  1. 의존성 주입: 생성자를 통해 의존성을 주입하여 결합도를 낮추세요.
  2. Provider 활용: 앱 루트에서 서비스와 리포지토리를 등록하고 context.read()로 조회하세요.
  3. 레이어별 테스트: 각 레이어는 직접적인 의존성만 Fake로 교체하여 테스트하세요.
  4. Fake 구현체: 인터페이스를 구현한 Fake를 만들어 다양한 시나리오를 테스트하세요.

다음 튜토리얼에서는 아키텍처 권장사항과 디자인 패턴을 살펴보겠습니다.


Footnotes#

  1. 의존성 주입(Dependency Injection): 객체가 필요로 하는 의존성을 외부에서 주입받는 설계 패턴입니다. 객체 생성의 책임을 분리하여 테스트와 유지보수를 용이하게 합니다.

  2. Provider: Flutter 팀이 권장하는 상태 관리 및 의존성 주입 패키지입니다. InheritedWidget을 간편하게 사용할 수 있도록 래핑한 도구입니다.

공유

이 글이 도움이 되었다면 다른 사람과 공유해주세요!

Flutter 튜토리얼 42편: 의존성 주입과 레이어 테스팅
https://moodturnpost.net/posts/flutter/flutter-architecture-di-testing/
작성자
Moodturn
게시일
2026-01-08
Moodturn

목차