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를 기반으로 위젯 테스트 작성 방법을 설명합니다.

참고 자료#


위젯 테스트 기초#

Why - 왜 위젯 테스트가 필요한가요?#

유닛 테스트는 비즈니스 로직만 검증합니다. 하지만 사용자는 UI를 통해 앱과 상호작용하므로, UI가 올바르게 렌더링되고 동작하는지도 확인해야 합니다. 위젯 테스트는 유닛 테스트의 속도와 통합 테스트의 신뢰도 사이에서 균형을 맞춥니다.

What - 위젯 테스트의 특징#

위젯 테스트1는 단일 위젯(컴포넌트)을 테스트합니다.

특징설명
환경실제 디바이스나 에뮬레이터 없이 테스트 환경에서 실행
속도유닛 테스트만큼 빠름
범위위젯의 렌더링, 레이아웃, 상호작용 검증
신뢰도유닛 테스트보다 높음, 통합 테스트보다 낮음

How - 첫 번째 위젯 테스트 작성하기#

테스트할 위젯 작성

lib/my_widget.dart
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)),
),
);
}
}

테스트 파일 작성

test/my_widget_test.dart
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 추가

lib/long_list.dart
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(capturedEmail, '[email protected]');
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 변경에 덜 민감해집니다.

마무리#

위젯 테스트의 핵심을 정리하면 다음과 같습니다.

  1. testWidgets와 WidgetTester: 위젯을 빌드하고 조작하는 테스트 환경
  2. pumpWidget과 pump: 위젯 빌드와 상태 변경 후 리빌드 트리거
  3. Finder: find.text(), find.byKey(), find.byType() 등으로 위젯 찾기
  4. 사용자 상호작용: tap(), enterText(), drag()로 사용자 동작 시뮬레이션
  5. 스크롤: scrollUntilVisible()로 긴 리스트에서 아이템 찾기

위젯 테스트는 유닛 테스트보다 더 현실적인 환경에서 UI를 검증합니다. 적절한 위젯 테스트로 UI 버그를 사전에 발견하세요.


Footnotes#

  1. 위젯 테스트(Widget Test): 단일 위젯의 UI와 상호작용을 격리된 테스트 환경에서 검증하는 방법입니다. 컴포넌트 테스트(Component Test)라고도 합니다.

공유

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

Flutter 튜토리얼 45편: 위젯 테스트
https://moodturnpost.net/posts/flutter/flutter-testing-widget/
작성자
Moodturn
게시일
2026-01-08
Moodturn

목차