Flutter 튜토리얼 42편: 의존성 주입과 레이어 테스팅
요약
핵심 요지
- 의존성 주입은 객체 생성 문제를 해결하여 테스트와 유지보수를 쉽게 만듭니다.
- Provider 패키지를 사용하여 앱 루트에서 서비스와 리포지토리를 제공합니다.
- 각 레이어는 직접적인 의존성만 모킹하여 독립적으로 테스트합니다.
- Fake 구현체를 만들어 Unit 테스트와 Widget 테스트에서 재사용합니다.
문서가 설명하는 범위
Flutter 공식 문서의 Dependency injection과 Testing을 기반으로 의존성 주입 패턴 구현 방법과 MVVM 아키텍처에서 각 레이어를 테스트하는 전략을 설명합니다.
참고 자료
의존성 주입 기초
Why - 왜 의존성 주입이 필요한가요?
앱의 컴포넌트들은 서로 의존합니다. ViewModel은 Repository가 필요하고, Repository는 Service가 필요합니다. 이때 각 컴포넌트가 자신의 의존성을 직접 생성하면 문제가 생깁니다.
// 나쁜 예: 내부에서 의존성 생성class HomeViewModel extends ChangeNotifier { // 테스트 시 ApiClient를 교체할 수 없음 final _repository = BookingRepository(ApiClient());}의존성 주입(Dependency Injection)1은 컴포넌트가 의존성을 스스로 생성하는 대신 외부에서 주입받는 패턴입니다. 이렇게 하면 테스트 시 Mock 객체를 주입할 수 있고, 구현체를 쉽게 교체할 수 있습니다.
What - 의존성 주입이란 무엇인가요?
의존성 주입은 객체 생성의 책임을 분리하는 설계 패턴입니다. 의존성을 생성자를 통해 전달받으므로 컴포넌트 간 결합도가 낮아집니다.
레이어 간 통신 규칙은 다음과 같습니다.
| 컴포넌트 | 규칙 |
|---|---|
| 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는 두 가지 주요 역할을 합니다.
- 의존성 등록: 앱 루트에서 Service와 Repository를 생성하고 등록합니다.
- 의존성 조회: 위젯 트리 어디서든
context.read()로 의존성을 가져옵니다.
How - Provider 구현하기
1단계: 앱 루트에서 Provider 설정
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 생성
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 사용
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로 교체하면 됩니다.
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.dartvoid 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이 필요합니다.
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 호출 없이 다양한 시나리오를 테스트할 수 있습니다.
How - Repository 테스트하기
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.dartvoid 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 - 테스트 앱 헬퍼 구현
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로 감싸서 테스트 실패를 방지하세요.
테스트 전략 정리
레이어별 테스트 책임
| 테스트 대상 | 모킹 대상 | 테스트 종류 |
|---|---|---|
| ViewModel | Repository | Unit 테스트 |
| View | ViewModel, Router | Widget 테스트 |
| Repository | Service | Unit 테스트 |
좋은 테스트 작성 원칙
- 입력과 출력에 집중: 구현 세부사항이 아닌 동작을 테스트하세요.
- Fake 재사용: ViewModel 테스트용 Fake를 Widget 테스트에서도 활용하세요.
- 모듈화된 함수: 작고 테스트하기 쉬운 함수를 작성하세요.
- 환경 분리: 추상 인터페이스로 개발/스테이징/프로덕션 환경을 분리하세요.
마무리
의존성 주입과 레이어 테스팅의 핵심을 정리하면 다음과 같습니다.
- 의존성 주입: 생성자를 통해 의존성을 주입하여 결합도를 낮추세요.
- Provider 활용: 앱 루트에서 서비스와 리포지토리를 등록하고
context.read()로 조회하세요. - 레이어별 테스트: 각 레이어는 직접적인 의존성만 Fake로 교체하여 테스트하세요.
- Fake 구현체: 인터페이스를 구현한 Fake를 만들어 다양한 시나리오를 테스트하세요.
다음 튜토리얼에서는 아키텍처 권장사항과 디자인 패턴을 살펴보겠습니다.
Footnotes
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!