Flutter 튜토리얼 45편: 위젯 테스트
요약
핵심 요지
- 위젯 테스트는 단일 위젯의 UI와 상호작용을 격리된 환경에서 검증합니다.
testWidgets()와WidgetTester로 위젯을 빌드하고 조작합니다.find상수의 다양한 Finder로 테스트 대상 위젯을 찾습니다.tester.tap(),tester.enterText(),tester.drag()로 사용자 상호작용을 시뮬레이션합니다.
문서가 설명하는 범위
Flutter 공식 문서의 Widget testing introduction, Find widgets, Handle scrolling, Tap, drag, and enter text를 기반으로 위젯 테스트 작성 방법을 설명합니다.
참고 자료
- Widget testing introduction - Flutter
- Find widgets - Flutter
- Handle scrolling - Flutter
- Tap, drag, and enter text - Flutter
위젯 테스트 기초
Why - 왜 위젯 테스트가 필요한가요?
유닛 테스트는 비즈니스 로직만 검증합니다. 하지만 사용자는 UI를 통해 앱과 상호작용하므로, UI가 올바르게 렌더링되고 동작하는지도 확인해야 합니다. 위젯 테스트는 유닛 테스트의 속도와 통합 테스트의 신뢰도 사이에서 균형을 맞춥니다.
What - 위젯 테스트의 특징
위젯 테스트1는 단일 위젯(컴포넌트)을 테스트합니다.
| 특징 | 설명 |
|---|---|
| 환경 | 실제 디바이스나 에뮬레이터 없이 테스트 환경에서 실행 |
| 속도 | 유닛 테스트만큼 빠름 |
| 범위 | 위젯의 렌더링, 레이아웃, 상호작용 검증 |
| 신뢰도 | 유닛 테스트보다 높음, 통합 테스트보다 낮음 |
How - 첫 번째 위젯 테스트 작성하기
테스트할 위젯 작성
import 'package:flutter/material.dart';
class MyWidget extends StatelessWidget { final String title; final String message;
const MyWidget({ super.key, required this.title, required this.message, });
@override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', home: Scaffold( appBar: AppBar(title: Text(title)), body: Center(child: Text(message)), ), ); }}테스트 파일 작성
import 'package:flutter/material.dart';import 'package:flutter_test/flutter_test.dart';import 'package:my_app/my_widget.dart';
void main() { testWidgets('MyWidget이 제목과 메시지를 표시한다', (tester) async { // 1. 위젯 빌드 await tester.pumpWidget( const MyWidget(title: '테스트 제목', message: '테스트 메시지'), );
// 2. 위젯 찾기 final titleFinder = find.text('테스트 제목'); final messageFinder = find.text('테스트 메시지');
// 3. 검증 expect(titleFinder, findsOneWidget); expect(messageFinder, findsOneWidget); });}How - 핵심 함수와 클래스
| 구성 요소 | 역할 |
|---|---|
testWidgets() | 위젯 테스트 케이스 정의, WidgetTester 제공 |
WidgetTester | 위젯 빌드, 상호작용, 상태 확인 |
pumpWidget() | 테스트 환경에 위젯 렌더링 |
Finder | 위젯 트리에서 위젯 검색 |
Matcher | 검색 결과 검증 (findsOneWidget 등) |
Watch out - 주의사항
flutter_test패키지는 Flutter SDK에 포함되어 있습니다.testWidgets()는 각 테스트마다 새로운WidgetTester를 제공합니다.- 비동기 작업이므로
async/await를 사용해야 합니다.
pumpWidget과 pump
Why - 왜 pump가 필요한가요?
Flutter는 선언적 UI 프레임워크로, 상태가 바뀌면 위젯을 다시 빌드합니다.
테스트 환경에서는 이 과정이 자동으로 일어나지 않으므로, pump()로 수동으로 프레임을 트리거해야 합니다.
What - pump 메서드의 종류
// pumpWidget: 위젯을 처음 빌드await tester.pumpWidget(MyWidget());
// pump: 프레임 하나 진행 (상태 변경 후 리빌드)await tester.pump();
// pump(duration): 지정된 시간만큼 진행await tester.pump(const Duration(milliseconds: 100));
// pumpAndSettle: 모든 애니메이션이 완료될 때까지 대기await tester.pumpAndSettle();How - pump 사용 예시
testWidgets('버튼 클릭 시 카운터가 증가한다', (tester) async { await tester.pumpWidget(const CounterApp());
// 초기 상태 확인 expect(find.text('0'), findsOneWidget);
// 버튼 탭 await tester.tap(find.byIcon(Icons.add));
// 상태 변경 전에는 아직 0 expect(find.text('0'), findsOneWidget);
// pump로 리빌드 트리거 await tester.pump();
// 이제 1로 업데이트됨 expect(find.text('1'), findsOneWidget);});testWidgets('애니메이션이 완료되면 결과가 표시된다', (tester) async { await tester.pumpWidget(const AnimatedWidget());
// 애니메이션 트리거 await tester.tap(find.byType(ElevatedButton));
// 애니메이션이 완료될 때까지 대기 await tester.pumpAndSettle();
// 최종 상태 확인 expect(find.text('완료'), findsOneWidget);});Watch out - 주의사항
- 상태 변경 후에는 반드시
pump()나pumpAndSettle()을 호출하세요. - 무한 애니메이션이 있으면
pumpAndSettle()이 타임아웃됩니다. - 특정 시점의 애니메이션 상태를 테스트하려면
pump(Duration)을 사용하세요.
Finder로 위젯 찾기
Why - 왜 다양한 Finder가 필요한가요?
위젯 트리에서 특정 위젯을 찾는 방법은 여러 가지입니다. 텍스트로 찾을 수도 있고, Key로 찾을 수도 있고, 타입으로 찾을 수도 있습니다. 상황에 맞는 Finder를 선택해야 테스트가 명확하고 유지보수하기 쉬워집니다.
What - 자주 사용하는 Finder
| Finder | 설명 | 사용 시점 |
|---|---|---|
find.text() | 특정 텍스트를 가진 Text 위젯 | 화면에 표시된 텍스트 확인 |
find.byKey() | 특정 Key를 가진 위젯 | 동일 텍스트가 여러 개일 때 |
find.byType() | 특정 타입의 위젯 | 위젯 존재 여부 확인 |
find.byWidget() | 특정 위젯 인스턴스 | child로 전달된 위젯 확인 |
find.byIcon() | 특정 아이콘 | 아이콘 버튼 찾기 |
find.byTooltip() | 특정 툴팁 | 접근성 텍스트로 찾기 |
How - Finder 사용 예시
find.text() - 텍스트로 찾기
testWidgets('환영 메시지가 표시된다', (tester) async { await tester.pumpWidget(const MaterialApp( home: Scaffold(body: Text('환영합니다')), ));
expect(find.text('환영합니다'), findsOneWidget);});find.byKey() - Key로 찾기
동일한 텍스트가 여러 개 있을 때 유용합니다.
testWidgets('특정 아이템을 Key로 찾는다', (tester) async { await tester.pumpWidget(MaterialApp( home: ListView( children: [ ListTile(key: const Key('item_1'), title: const Text('아이템')), ListTile(key: const Key('item_2'), title: const Text('아이템')), ListTile(key: const Key('item_3'), title: const Text('아이템')), ], ), ));
// 텍스트로 찾으면 3개가 나옴 expect(find.text('아이템'), findsNWidgets(3));
// Key로 특정 아이템을 찾음 expect(find.byKey(const Key('item_2')), findsOneWidget);});find.byType() - 타입으로 찾기
testWidgets('AppBar가 존재한다', (tester) async { await tester.pumpWidget(const MaterialApp( home: Scaffold( appBar: AppBar(title: Text('제목')), ), ));
expect(find.byType(AppBar), findsOneWidget); expect(find.byType(FloatingActionButton), findsNothing);});find.byWidget() - 위젯 인스턴스로 찾기
testWidgets('child 위젯이 렌더링된다', (tester) async { const childWidget = Padding( padding: EdgeInsets.all(16), child: Text('자식 위젯'), );
await tester.pumpWidget(const MaterialApp( home: Scaffold(body: childWidget), ));
expect(find.byWidget(childWidget), findsOneWidget);});How - Finder 조합하기
testWidgets('특정 위젯 내부의 텍스트를 찾는다', (tester) async { await tester.pumpWidget(MaterialApp( home: Column( children: [ Card( key: const Key('card_1'), child: const Text('카드 1 내용'), ), Card( key: const Key('card_2'), child: const Text('카드 2 내용'), ), ], ), ));
// descendant: 특정 위젯의 자손 중에서 찾기 expect( find.descendant( of: find.byKey(const Key('card_1')), matching: find.text('카드 1 내용'), ), findsOneWidget, );
// ancestor: 특정 위젯의 조상 중에서 찾기 expect( find.ancestor( of: find.text('카드 2 내용'), matching: find.byType(Card), ), findsOneWidget, );});Watch out - 주의사항
- 가능하면
find.byKey()를 사용하세요. 텍스트보다 안정적입니다. - 테스트용 Key는 프로덕션 코드에 영향을 주지 않습니다.
findsOneWidget,findsNothing,findsNWidgets(n)등의 Matcher를 상황에 맞게 사용하세요.
사용자 상호작용 시뮬레이션
Why - 왜 상호작용 테스트가 필요한가요?
앱은 사용자의 탭, 스와이프, 텍스트 입력에 반응합니다. 이런 상호작용이 올바르게 처리되는지 테스트해야 실제 사용 환경에서의 문제를 사전에 발견할 수 있습니다.
What - WidgetTester의 상호작용 메서드
| 메서드 | 설명 |
|---|---|
tap() | 위젯 탭 |
doubleTap() | 더블 탭 |
longPress() | 길게 누르기 |
enterText() | 텍스트 입력 |
drag() | 드래그 |
fling() | 빠른 스와이프 |
scrollUntilVisible() | 보일 때까지 스크롤 |
How - 탭 테스트
testWidgets('버튼 탭 시 다이얼로그가 표시된다', (tester) async { await tester.pumpWidget(MaterialApp( home: Scaffold( body: Builder( builder: (context) => ElevatedButton( onPressed: () { showDialog( context: context, builder: (_) => const AlertDialog( title: Text('알림'), content: Text('버튼이 클릭되었습니다'), ), ); }, child: const Text('버튼'), ), ), ), ));
// 버튼 탭 await tester.tap(find.text('버튼')); await tester.pumpAndSettle();
// 다이얼로그 확인 expect(find.text('알림'), findsOneWidget); expect(find.text('버튼이 클릭되었습니다'), findsOneWidget);});How - 텍스트 입력 테스트
testWidgets('텍스트 입력이 동작한다', (tester) async { await tester.pumpWidget(MaterialApp( home: Scaffold( body: TextField( key: const Key('input_field'), ), ), ));
// 텍스트 입력 await tester.enterText(find.byKey(const Key('input_field')), '안녕하세요'); await tester.pump();
// 입력된 텍스트 확인 expect(find.text('안녕하세요'), findsOneWidget);});How - 드래그와 스와이프 테스트
testWidgets('스와이프로 아이템을 삭제한다', (tester) async { await tester.pumpWidget(MaterialApp( home: Scaffold( body: Dismissible( key: const Key('dismissible_item'), onDismissed: (_) {}, child: const ListTile(title: Text('삭제할 아이템')), ), ), ));
// 아이템 존재 확인 expect(find.text('삭제할 아이템'), findsOneWidget);
// 오른쪽으로 스와이프 await tester.drag( find.byKey(const Key('dismissible_item')), const Offset(500, 0), );
// 애니메이션 완료 대기 await tester.pumpAndSettle();
// 아이템이 삭제됨 expect(find.text('삭제할 아이템'), findsNothing);});How - 폼 테스트 통합 예제
testWidgets('Todo 앱: 추가와 삭제', (tester) async { await tester.pumpWidget(const TodoApp());
// 1. 텍스트 입력 await tester.enterText(find.byType(TextField), '우유 사기');
// 2. 추가 버튼 탭 await tester.tap(find.byType(FloatingActionButton)); await tester.pump();
// 3. 아이템이 추가됨 expect(find.text('우유 사기'), findsOneWidget);
// 4. 스와이프로 삭제 await tester.drag( find.byType(Dismissible), const Offset(500, 0), ); await tester.pumpAndSettle();
// 5. 아이템이 삭제됨 expect(find.text('우유 사기'), findsNothing);});Watch out - 주의사항
- 상호작용 후에는 반드시
pump()또는pumpAndSettle()을 호출하세요. enterText()는 기존 텍스트를 대체합니다. 추가하려면 먼저 텍스트를 가져와야 합니다.- 애니메이션이 포함된 동작은
pumpAndSettle()을 사용하세요.
스크롤 테스트
Why - 왜 스크롤 테스트가 필요한가요?
긴 리스트에서 특정 아이템이 보이지 않는 영역에 있을 수 있습니다. 스크롤하여 아이템을 찾고 상호작용하는 테스트가 필요합니다.
What - scrollUntilVisible의 동작
scrollUntilVisible()은 대상 위젯이 화면에 나타날 때까지 반복적으로 스크롤합니다.
아이템 높이나 디바이스 크기를 몰라도 사용할 수 있어 편리합니다.
How - 스크롤 테스트 작성하기
앱에 Key 추가
class LongListApp extends StatelessWidget { final List<String> items;
const LongListApp({super.key, required this.items});
@override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( body: ListView.builder( key: const Key('long_list'), itemCount: items.length, itemBuilder: (context, index) { return ListTile( key: Key('item_${index}_tile'), title: Text( items[index], key: Key('item_${index}_text'), ), ); }, ), ), ); }}스크롤 테스트 작성
testWidgets('긴 리스트에서 특정 아이템을 찾는다', (tester) async { // 10000개의 아이템 생성 await tester.pumpWidget( LongListApp( items: List<String>.generate(10000, (i) => 'Item $i'), ), );
// 스크롤 가능한 위젯 찾기 final listFinder = find.byType(Scrollable);
// 찾을 아이템의 Finder final itemFinder = find.byKey(const Key('item_50_text'));
// 아이템이 보일 때까지 스크롤 await tester.scrollUntilVisible( itemFinder, 500.0, // 스크롤 거리 scrollable: listFinder, );
// 아이템이 화면에 있음을 확인 expect(itemFinder, findsOneWidget);});How - 수동 스크롤 테스트
testWidgets('수동으로 스크롤한다', (tester) async { await tester.pumpWidget( LongListApp(items: List.generate(100, (i) => 'Item $i')), );
// fling: 빠른 스와이프 await tester.fling( find.byType(ListView), const Offset(0, -500), // 위로 스크롤 1000, // 속도 ); await tester.pumpAndSettle();
// drag: 드래그 await tester.drag( find.byType(ListView), const Offset(0, -200), ); await tester.pump();});Watch out - 주의사항
scrollUntilVisible()은 아이템이 없으면 타임아웃됩니다.- Key를 사용하면 특정 아이템을 정확히 찾을 수 있습니다.
- 스크롤 방향에 주의하세요: 음수 Y는 위로 스크롤, 양수 X는 오른쪽으로 스크롤입니다.
테스트 Matcher
What - 위젯 테스트 Matcher
// 위젯 존재 확인expect(finder, findsOneWidget); // 정확히 1개expect(finder, findsNothing); // 0개expect(finder, findsWidgets); // 1개 이상expect(finder, findsNWidgets(3)); // 정확히 3개expect(finder, findsAtLeast(2)); // 최소 2개
// 위젯 속성 확인expect( tester.widget<Text>(find.byKey(const Key('title'))), isA<Text>().having((t) => t.data, 'text', '제목'),);
// 위젯 상태 확인expect( tester.widget<Checkbox>(find.byType(Checkbox)), isA<Checkbox>().having((c) => c.value, 'checked', true),);How - 복잡한 검증
testWidgets('버튼 상태를 검증한다', (tester) async { await tester.pumpWidget(MaterialApp( home: Scaffold( body: ElevatedButton( onPressed: null, // 비활성화 child: const Text('비활성 버튼'), ), ), ));
// 버튼 위젯 가져오기 final button = tester.widget<ElevatedButton>( find.byType(ElevatedButton), );
// onPressed가 null인지 확인 (비활성화) expect(button.onPressed, isNull);});testWidgets('TextField 입력값을 검증한다', (tester) async { final controller = TextEditingController();
await tester.pumpWidget(MaterialApp( home: Scaffold( body: TextField(controller: controller), ), ));
await tester.enterText(find.byType(TextField), '테스트 입력'); await tester.pump();
// 컨트롤러 값 확인 expect(controller.text, '테스트 입력');});Watch out - 주의사항
tester.widget<T>()로 실제 위젯 인스턴스에 접근할 수 있습니다.- 위젯 속성을 직접 검사하면 더 정밀한 테스트가 가능합니다.
- 너무 구현에 의존하는 테스트는 유지보수가 어려울 수 있습니다.
실전 위젯 테스트
로그인 폼 테스트
testWidgets('로그인 폼이 올바르게 동작한다', (tester) async { bool loginCalled = false; String? capturedEmail; String? capturedPassword;
await tester.pumpWidget(MaterialApp( home: LoginForm( onLogin: (email, password) { loginCalled = true; capturedEmail = email; capturedPassword = password; }, ), ));
// 이메일 입력 await tester.enterText( find.byKey(const Key('email_field')), );
// 비밀번호 입력 await tester.enterText( find.byKey(const Key('password_field')), 'password123', );
// 로그인 버튼 탭 await tester.tap(find.byKey(const Key('login_button'))); await tester.pump();
// 콜백 호출 확인 expect(loginCalled, isTrue); expect(capturedPassword, 'password123');});로딩 상태 테스트
testWidgets('로딩 중에 인디케이터가 표시된다', (tester) async { await tester.pumpWidget(MaterialApp( home: LoadingScreen(isLoading: true), ));
// 로딩 인디케이터 확인 expect(find.byType(CircularProgressIndicator), findsOneWidget); expect(find.text('데이터'), findsNothing);});
testWidgets('로딩 완료 후 데이터가 표시된다', (tester) async { await tester.pumpWidget(MaterialApp( home: LoadingScreen(isLoading: false), ));
// 데이터 표시 확인 expect(find.byType(CircularProgressIndicator), findsNothing); expect(find.text('데이터'), findsOneWidget);});에러 상태 테스트
testWidgets('에러 발생 시 에러 메시지가 표시된다', (tester) async { await tester.pumpWidget(MaterialApp( home: DataScreen( error: '네트워크 오류가 발생했습니다', ), ));
expect(find.text('네트워크 오류가 발생했습니다'), findsOneWidget); expect(find.byIcon(Icons.error), findsOneWidget);});Watch out - 주의사항
- 각 상태(로딩, 성공, 에러)를 별도 테스트로 작성하세요.
- 콜백 함수는 테스트에서 캡처하여 호출 여부를 확인하세요.
- Key를 활용하면 테스트가 UI 변경에 덜 민감해집니다.
마무리
위젯 테스트의 핵심을 정리하면 다음과 같습니다.
- testWidgets와 WidgetTester: 위젯을 빌드하고 조작하는 테스트 환경
- pumpWidget과 pump: 위젯 빌드와 상태 변경 후 리빌드 트리거
- Finder:
find.text(),find.byKey(),find.byType()등으로 위젯 찾기 - 사용자 상호작용:
tap(),enterText(),drag()로 사용자 동작 시뮬레이션 - 스크롤:
scrollUntilVisible()로 긴 리스트에서 아이템 찾기
위젯 테스트는 유닛 테스트보다 더 현실적인 환경에서 UI를 검증합니다. 적절한 위젯 테스트로 UI 버그를 사전에 발견하세요.
Footnotes
-
위젯 테스트(Widget Test): 단일 위젯의 UI와 상호작용을 격리된 테스트 환경에서 검증하는 방법입니다. 컴포넌트 테스트(Component Test)라고도 합니다. ↩
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!