Flutter에서 위젯 테스트를 어떻게 작성하나요?
질문
Flutter 앱에서 위젯 테스트를 작성하는 방법과 테스트 케이스 구성에 대해 설명해주세요.
답변
Flutter에서 위젯 테스트는 UI 컴포넌트가 예상대로 동작하는지 검증하는 중요한 과정입니다. 위젯 테스트를 통해 UI 레이아웃, 사용자 상호작용, 상태 변경 등을 자동화된 방식으로 확인할 수 있습니다.
1. 위젯 테스트 기본 설정
1.1 의존성 추가
Flutter SDK에는 테스트 라이브러리가 기본으로 포함되어 있지만, pubspec.yaml
파일에 명시적으로 추가할 수 있습니다:
dev_dependencies:
flutter_test:
sdk: flutter
mockito: ^5.4.2 # 목킹(mocking)을 위한 라이브러리
fake_async: ^1.3.1 # 비동기 테스트를 위한 라이브러리
1.2 테스트 파일 구조
테스트 파일은 일반적으로 test
디렉토리에 위치하며, 파일명은 _test.dart
로 끝나야 합니다:
my_app/
lib/
widgets/
counter_widget.dart
test/
widget/
counter_widget_test.dart
2. 기본 위젯 테스트 작성하기
2.1 간단한 위젯 테스트 예시
다음은 텍스트를 표시하는 간단한 위젯에 대한 테스트 예시입니다:
// lib/widgets/greeting.dart
import 'package:flutter/material.dart';
class GreetingWidget extends StatelessWidget {
final String name;
const GreetingWidget({Key? key, required this.name}) : super(key: key);
@override
Widget build(BuildContext context) {
return Text('안녕하세요, $name님!');
}
}
이 위젯에 대한 테스트 코드:
// test/widget/greeting_widget_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/widgets/greeting.dart';
void main() {
testWidgets('GreetingWidget는 이름과 함께 인사말을 표시해야 합니다', (WidgetTester tester) async {
// 위젯 펌핑(화면에 렌더링)
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: GreetingWidget(name: '홍길동'),
),
),
);
// 예상한 텍스트가 있는지 확인
expect(find.text('안녕하세요, 홍길동님!'), findsOneWidget);
// 존재하지 않는 텍스트 확인
expect(find.text('안녕하세요!'), findsNothing);
});
}
3. WidgetTester 활용하기
WidgetTester
는 위젯과 상호작용하고 UI 상태를 조작하는 다양한 메서드를 제공합니다:
3.1 위젯 펌핑 메서드
// 위젯을 한 번 빌드
await tester.pumpWidget(widget);
// 애니메이션이 완료될 때까지 펌핑
await tester.pumpAndSettle();
// 특정 시간 동안 펌핑
await tester.pump(Duration(seconds: 1));
3.2 위젯 찾기
// Key로 위젯 찾기
expect(find.byKey(const Key('submit-button')), findsOneWidget);
// 타입으로 위젯 찾기
expect(find.byType(ElevatedButton), findsNWidgets(2));
// 텍스트로 위젯 찾기
expect(find.text('로그인'), findsOneWidget);
// 아이콘으로 위젯 찾기
expect(find.byIcon(Icons.add), findsOneWidget);
// 위젯 인스턴스로 찾기
expect(find.byWidget(myWidget), findsOneWidget);
// 시맨틱 레이블로 찾기
expect(find.bySemanticsLabel('접근성 레이블'), findsOneWidget);
// 툴팁으로 찾기
expect(find.byTooltip('항목 추가'), findsOneWidget);
3.3 사용자 상호작용 시뮬레이션
// 탭(클릭) 시뮬레이션
await tester.tap(find.byType(ElevatedButton));
await tester.pump(); // UI 업데이트를 위해 펌프
// 텍스트 입력
await tester.enterText(find.byType(TextField), '테스트 텍스트');
await tester.pump();
// 드래그 시뮬레이션
await tester.drag(find.byType(Slider), const Offset(20.0, 0.0));
await tester.pump();
// 스크롤 시뮬레이션
await tester.dragFrom(const Offset(0, 300), const Offset(0, -300));
await tester.pump();
// 롱 프레스 시뮬레이션
await tester.longPress(find.byType(ListTile));
await tester.pump();
4. 복잡한 위젯 테스트 예시
아래는 카운터 기능이 있는 위젯의 테스트 예시입니다:
// lib/widgets/counter.dart
import 'package:flutter/material.dart';
class CounterWidget extends StatefulWidget {
const CounterWidget({Key? key}) : super(key: key);
@override
_CounterWidgetState createState() => _CounterWidgetState();
}
class _CounterWidgetState extends State<CounterWidget> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'현재 카운트:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
key: const Key('counter-value'),
),
ElevatedButton(
onPressed: _incrementCounter,
child: const Text('증가'),
key: const Key('increment-button'),
),
],
);
}
}
테스트 코드:
// test/widget/counter_widget_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/widgets/counter.dart';
void main() {
group('CounterWidget', () {
testWidgets('초기 상태에서 카운터가 0이어야 합니다', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: CounterWidget())));
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
});
testWidgets('버튼을 누르면 카운터가 증가해야 합니다', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: CounterWidget())));
// 버튼 찾기 및 탭
await tester.tap(find.byKey(const Key('increment-button')));
await tester.pump(); // UI 업데이트
// 카운터 값 확인
expect(find.text('1'), findsOneWidget);
expect(find.text('0'), findsNothing);
// 한 번 더 버튼 탭
await tester.tap(find.byKey(const Key('increment-button')));
await tester.pump();
// 업데이트된 카운터 값 확인
expect(find.text('2'), findsOneWidget);
});
});
}
5. Matcher를 활용한 고급 검증
Flutter 테스트 프레임워크는 다양한 Matcher를 제공하여 정밀한 검증이 가능합니다:
// 위젯 개수 검증
expect(find.byType(ListTile), findsNWidgets(5));
// 정확히 한 개 검증
expect(find.byType(AppBar), findsOneWidget);
// 위젯이 없는지 검증
expect(find.byType(CircularProgressIndicator), findsNothing);
// 위젯 속성 검증
final textWidget = tester.widget<Text>(find.byKey(const Key('title')));
expect(textWidget.style?.fontSize, 24.0);
expect(textWidget.style?.fontWeight, FontWeight.bold);
// 위젯 상태 검증
final sliderWidget = tester.widget<Slider>(find.byType(Slider));
expect(sliderWidget.value, closeTo(0.5, 0.01));
// 리스트 검증
expect(listItems, hasLength(3));
expect(listItems, contains('항목1'));
// 에러 발생 검증
expect(() => myFunction(), throwsA(isA<ArgumentError>()));
6. 다양한 테스트 시나리오
6.1 폼 테스트하기
testWidgets('폼 제출 테스트', (WidgetTester tester) async {
bool formSubmitted = false;
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: LoginForm(
onSubmit: (email, password) {
formSubmitted = true;
expect(email, 'test@example.com');
expect(password, 'password123');
},
),
),
));
// 이메일 입력
await tester.enterText(
find.byKey(const Key('email-field')),
'test@example.com'
);
// 비밀번호 입력
await tester.enterText(
find.byKey(const Key('password-field')),
'password123'
);
// 폼 제출 버튼 탭
await tester.tap(find.byKey(const Key('submit-button')));
await tester.pump();
// 폼이 제출되었는지 확인
expect(formSubmitted, true);
// 성공 메시지 확인
expect(find.text('로그인 성공!'), findsOneWidget);
});
6.2 스크롤 테스트하기
testWidgets('ListView 스크롤 테스트', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: ListView.builder(
itemCount: 100,
itemBuilder: (context, index) => ListTile(
title: Text('항목 $index'),
key: Key('item-$index'),
),
),
),
));
// 첫 번째 항목 확인
expect(find.text('항목 0'), findsOneWidget);
// 화면 밖 항목은 보이지 않아야 함
expect(find.text('항목 20'), findsNothing);
// 아래로 스크롤
await tester.dragFrom(
tester.getCenter(find.byType(ListView)),
const Offset(0, -500)
);
await tester.pump();
// 스크롤 후 새로 보이는 항목 확인
expect(find.text('항목 20'), findsOneWidget);
// 이전 항목은 이제 보이지 않아야 함
expect(find.text('항목 0'), findsNothing);
});
6.3 네비게이션 테스트하기
testWidgets('네비게이션 테스트', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(
routes: {
'/': (context) => HomeScreen(),
'/details': (context) => DetailScreen(),
},
));
// 홈 화면에 '상세 보기' 버튼이 있는지 확인
expect(find.text('상세 보기'), findsOneWidget);
// 버튼 탭하여 네비게이션
await tester.tap(find.text('상세 보기'));
await tester.pumpAndSettle(); // 애니메이션 완료까지 대기
// 상세 화면으로 이동했는지 확인
expect(find.text('상세 화면'), findsOneWidget);
expect(find.text('상세 보기'), findsNothing);
// 뒤로 가기 버튼 확인 및 탭
expect(find.byType(BackButton), findsOneWidget);
await tester.tap(find.byType(BackButton));
await tester.pumpAndSettle();
// 홈 화면으로 돌아왔는지 확인
expect(find.text('상세 보기'), findsOneWidget);
});
7. 비동기 작업 테스트하기
7.1 FutureBuilder 테스트
testWidgets('FutureBuilder 테스트', (WidgetTester tester) async {
// 데이터 로딩 위젯 생성
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: DataLoadingWidget(
future: Future.delayed(
const Duration(seconds: 1),
() => '로딩된 데이터',
),
),
),
));
// 초기 로딩 상태 확인
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// 1초 기다림
await tester.pump(const Duration(seconds: 1));
// 로딩 완료 후 데이터 표시 확인
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.text('로딩된 데이터'), findsOneWidget);
});
7.2 fake_async 활용하기
testWidgets('타이머 테스트', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: TimerWidget(),
),
));
// 초기 상태 확인
expect(find.text('0초'), findsOneWidget);
// 시작 버튼 탭
await tester.tap(find.text('시작'));
await tester.pump();
// 1초 지남
await tester.pump(const Duration(seconds: 1));
expect(find.text('1초'), findsOneWidget);
// 추가로 2초 지남
await tester.pump(const Duration(seconds: 2));
expect(find.text('3초'), findsOneWidget);
});
8. 상태 관리 테스트하기
8.1 Provider를 사용한 상태 관리 테스트
testWidgets('Provider 상태 관리 테스트', (WidgetTester tester) async {
final counterModel = CounterModel();
await tester.pumpWidget(
ChangeNotifierProvider<CounterModel>.value(
value: counterModel,
child: MaterialApp(
home: Scaffold(
body: CounterConsumerWidget(),
),
),
),
);
// 초기 상태 확인
expect(find.text('0'), findsOneWidget);
// 모델 상태 변경
counterModel.increment();
await tester.pump();
// 변경된 상태가 UI에 반영되었는지 확인
expect(find.text('1'), findsOneWidget);
// 위젯에서 상태 변경
await tester.tap(find.byType(ElevatedButton));
await tester.pump();
// 최종 상태 확인
expect(find.text('2'), findsOneWidget);
});
8.2 BLoC 패턴 테스트
testWidgets('BLoC 패턴 테스트', (WidgetTester tester) async {
final counterBloc = CounterBloc();
await tester.pumpWidget(
BlocProvider<CounterBloc>.value(
value: counterBloc,
child: MaterialApp(
home: Scaffold(
body: CounterBlocWidget(),
),
),
),
);
// 초기 상태 확인
expect(find.text('0'), findsOneWidget);
// 이벤트 발생
counterBloc.add(IncrementEvent());
await tester.pumpAndSettle();
// 상태 변화 확인
expect(find.text('1'), findsOneWidget);
});
9. 위젯 테스트와 통합 테스트의 차이
위젯 테스트:
- Flutter 테스트 환경 내에서 실행됨
- 실제 기기나 에뮬레이터 필요 없음
- 위젯의 렌더링과 동작 테스트에 중점
- 빠른 실행 속도
flutter_test
패키지 사용
통합 테스트:
- 실제 기기나 에뮬레이터에서 실행됨
- 여러 위젯과 서비스의 상호작용 테스트
- 실제 네트워크 요청, 데이터베이스 접근 등 가능
- 느린 실행 속도
integration_test
패키지 사용
10. 모의 객체(Mock)와 가짜 객체(Fake) 활용하기
10.1 Mockito를 활용한 의존성 모킹
// API 서비스 모킹
class MockApiService extends Mock implements ApiService {}
testWidgets('API 호출 테스트', (WidgetTester tester) async {
final mockApiService = MockApiService();
// 모의 응답 설정
when(mockApiService.fetchData()).thenAnswer(
(_) async => {'result': '성공', 'data': ['항목1', '항목2']}
);
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: DataWidget(apiService: mockApiService),
),
));
// 초기 로딩 상태 확인
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// 데이터 로딩 완료될 때까지 대기
await tester.pumpAndSettle();
// 모의 API 호출 확인
verify(mockApiService.fetchData()).called(1);
// UI에 데이터 표시 확인
expect(find.text('항목1'), findsOneWidget);
expect(find.text('항목2'), findsOneWidget);
});
10.2 Fake 클래스 생성
// 가짜 저장소 구현
class FakeRepository implements Repository {
final Map<String, dynamic> _storage = {};
@override
Future<void> saveData(String key, dynamic value) async {
_storage[key] = value;
}
@override
Future<dynamic> getData(String key) async {
return _storage[key];
}
}
testWidgets('저장소 테스트', (WidgetTester tester) async {
final fakeRepository = FakeRepository();
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: SettingsScreen(repository: fakeRepository),
),
));
// 설정 변경
await tester.tap(find.byKey(const Key('theme-switch')));
await tester.pump();
// 저장소에 값이 저장되었는지 확인
expect(await fakeRepository.getData('darkMode'), true);
});
11. 골든 테스트 (Golden Tests)
골든 테스트는 위젯의 시각적 출력을 스냅샷으로 저장하고 비교합니다:
testWidgets('골든 테스트 예시', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData.light(),
home: Scaffold(
body: Center(
child: ProfileCard(
name: '홍길동',
role: '개발자',
avatarUrl: 'https://example.com/avatar.jpg',
),
),
),
),
);
await expectLater(
find.byType(ProfileCard),
matchesGoldenFile('profile_card.png'),
);
});
이 테스트는 ProfileCard 위젯을 렌더링하고 그 모습을 profile_card.png
파일과 비교합니다. 첫 실행 시 참조 이미지가 생성되고, 이후 실행에서는 시각적 회귀를 감지합니다.
12. 테스트 커버리지 향상을 위한 팁
- 핵심 위젯 우선 테스트: 앱의 핵심 기능을 담당하는 위젯부터 테스트 작성
- 다양한 조건 테스트: 빈 상태, 로딩 상태, 오류 상태, 성공 상태 등 다양한 조건에서 테스트
- Edge Case 다루기: 경계값, 예외 상황, 극단적인 입력 등에 대한 테스트 포함
- 가독성 높은 테스트 코드: 각 테스트의 목적이 명확하도록 작성
- 그룹화와 설정 코드 재사용:
group
과setUp
/tearDown
활용
group('LoginScreen', () {
late MockAuthService mockAuthService;
setUp(() {
mockAuthService = MockAuthService();
});
testWidgets('유효한 자격 증명으로 로그인 성공', (tester) async { /* ... */ });
testWidgets('유효하지 않은 이메일로 로그인 실패', (tester) async { /* ... */ });
testWidgets('빈 필드가 있을 때 제출 버튼 비활성화', (tester) async { /* ... */ });
});
요약
Flutter 위젯 테스트는 UI 컴포넌트의 동작을 자동화된 방식으로 검증하는 강력한 도구입니다. 기본적인 테스트부터 복잡한 상호작용 테스트까지 다양한 시나리오를 다룰 수 있으며, 다음과 같은 이점을 제공합니다:
- 회귀 방지: 코드 변경이 기존 기능을 손상시키지 않도록 보장
- 문서화: 테스트는 위젯의 예상 동작을 문서화하는 역할
- 디자인 개선: 테스트하기 쉬운 코드는 일반적으로 더 모듈화되고 유지보수가 용이
- 개발자 신뢰도: 테스트된 코드는 개발자에게 더 큰 자신감 제공
- 효율성: 자동화된 테스트는 수동 QA 시간을 절약
위젯 테스트를 작성할 때는 테스트의 명확성, 가독성, 유지 관리성을 위해 위에서 설명한 모범 사례를 따르는 것이 중요합니다.