Flutter는 테스트를 세 가지 수준으로 나눈다. 아래로 갈수록 범위가 넓고, 실행 비용이 높다.
| 종류 | 대상 | 실행 환경 | 속도 |
|---|---|---|---|
| Unit Test | 함수, 클래스, 메서드 하나 | Dart VM (기기 불필요) | 매우 빠름 |
| Widget Test | 단일 위젯 렌더링 + 상호작용 | Dart VM (기기 불필요) | 빠름 |
| Integration Test | 앱 전체 또는 큰 기능 흐름 | 실기기 또는 에뮬레이터 필수 | 느림 (수십 초~수 분) |
integration_test는 Flutter SDK에 내장되어 있다. 별도 버전 지정 없이 sdk: flutter로 추가한다.
# pubspec.yaml
dev_dependencies:
flutter_test:
sdk: flutter
integration_test:
sdk: flutter
추가 후 flutter pub get을 실행한다.
프로젝트 루트에 다음 두 폴더를 만든다.
my_app/
integration_test/ # 통합 테스트 파일
app_test.dart # 메인 테스트
test_driver/ # (선택) 드라이버 설정
integration_test.dart # 드라이버 엔트리포인트
test_driver/ 없이도 flutter test integration_test/ 명령으로 직접 실행할 수 있다. test_driver/는 Chrome 등 웹 드라이버가 필요한 경우에만 사용한다.
웹이 아닌 모바일만 대상이라면 이 파일은 생략 가능하다. 필요 시 다음과 같이 작성한다.
import 'package:integration_test/integration_test_driver.dart';
Future<void> main() => integrationDriver();
통합 테스트 파일은 다음 패턴을 따른다.
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:my_app/main.dart' as app;
void main() {
// 반드시 맨 처음에 호출
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('홈 화면이 정상 표시된다', (tester) async {
app.main();
await tester.pumpAndSettle();
expect(find.text('환영합니다'), findsOneWidget);
});
}
| 메서드 | 설명 | 예시 |
|---|---|---|
find.text('...') | 정확히 일치하는 텍스트 | find.text('새로 만들기') |
find.textContaining('...') | 포함하는 텍스트 | find.textContaining('하이라이트') |
find.byType(Widget) | 위젯 타입으로 찾기 | find.byType(ElevatedButton) |
find.byKey(Key) | Key로 찾기 | find.byKey(Key('submit_btn')) |
find.byIcon(icon) | 아이콘으로 찾기 | find.byIcon(Icons.add) |
| 매처 | 의미 |
|---|---|
findsOneWidget | 정확히 1개 발견 |
findsWidgets | 1개 이상 발견 |
findsNothing | 0개 (화면에 없음) |
findsNWidgets(n) | 정확히 n개 발견 |
expect(find.text('출력 설정'), findsOneWidget);
expect(find.text('이전 화면'), findsNothing);
| 메서드 | 동작 |
|---|---|
tester.tap(finder) | 위젯 탭 (클릭) |
tester.enterText(finder, text) | 텍스트 필드에 입력 |
tester.drag(finder, offset) | 드래그 (스크롤) |
tester.longPress(finder) | 길게 누르기 |
tester.pump() | 한 프레임 렌더 |
tester.pumpAndSettle() | 애니메이션 완료까지 대기 |
// 버튼 탭 후 화면 전환 대기
await tester.tap(find.text('새로 만들기'));
await tester.pumpAndSettle(const Duration(seconds: 2));
// 텍스트 입력
await tester.enterText(find.byType(TextField), '검색어');
await tester.pumpAndSettle();
네트워크 요청, 파일 I/O 등 비동기 작업 후에는 pumpAndSettle()만으로 부족할 수 있다. 이 경우 타임아웃을 주거나, 특정 위젯이 나타날 때까지 폴링한다.
// 방법 1: 타임아웃 지정
await tester.pumpAndSettle(const Duration(seconds: 10));
// 방법 2: 특정 위젯이 나타날 때까지 폴링
bool found = false;
for (int i = 0; i < 30; i++) {
await tester.pump(const Duration(seconds: 1));
if (find.text('완료').evaluate().isNotEmpty) {
found = true;
break;
}
}
expect(found, isTrue);
관련된 테스트를 group()으로 묶어서 구조화할 수 있다.
group('앱 시작', () {
testWidgets('홈 화면이 정상 표시된다', (tester) async {
// ...
});
testWidgets('출력 설정 카드가 표시된다', (tester) async {
// ...
});
});
group('화면 전환', () {
testWidgets('새로 만들기 탭 시 다음 화면으로 이동', (tester) async {
// ...
});
});
# 연결된 기기/에뮬레이터에서 실행
flutter test integration_test/app_test.dart
# 특정 디바이스 지정
flutter test integration_test/app_test.dart -d <device-id>
# 에뮬레이터 예시
flutter test integration_test/app_test.dart -d emulator-5554
# 실기기 예시
flutter test integration_test/app_test.dart -d RFKYC0DKY7J
# 연결된 디바이스 목록
flutter devices
# 출력 예시:
# Samsung Galaxy S24 (mobile) - RFKYC0DKY7J - android-arm64
# sdk gphone64 arm64 (mobile) - emulator-5554 - android-arm64
현재 Flutter integration test에서는 파일 단위로 실행한다. 특정 testWidgets만 골라서 실행하는 기본 옵션은 없으므로, 필요하면 테스트 파일을 분리하거나 skip: true를 활용한다.
testWidgets('건너뛸 테스트', (tester) async {
// ...
}, skip: true); // 이 테스트는 건너뜀
| 항목 | 에뮬레이터 | 실기기 |
|---|---|---|
| 권한 다이얼로그 | 자동 승인되는 경우가 많음 | 시스템 다이얼로그가 실제로 뜸 (자동 제어 불가) |
| 사진/파일 접근 | 실제 데이터가 거의 없음 | 사용자의 실제 사진/동영상에 접근 가능 |
| 모션포토 | 모션포토 파일이 없어 스캔 결과 0건 | 실제 모션포토로 전체 흐름 테스트 가능 |
| 카메라 | 가상 카메라 (시뮬레이션) | 실제 카메라 동작 |
| 성능 | 호스트 PC 성능에 의존, 일반적으로 느림 | 기기 고유 성능, 대체로 빠름 |
| CI/CD 적합도 | 헤드리스 에뮬레이터로 CI에서 실행 가능 | 물리적 기기 연결 필요 (Firebase Test Lab 등) |
tester.tap()으로 제어할 수 없다. 실기기에서는 테스트 전에 미리 권한을 허용해 두거나, adb shell pm grant 명령으로 사전 부여하는 방법을 사용한다.
MotionStitch Lite에는 3개 그룹, 총 6개의 통합 테스트가 작성되어 있다.
| 테스트 | 검증 내용 |
|---|---|
| 홈 화면이 정상 표시된다 | 앱 타이틀(Motion / Stitch), 부제("하이라이트 요약 영상"), 핵심 버튼("새로 만들기", "내 무비 갤러리"), 버전 표시가 모두 존재하는지 확인 |
| 출력 설정 카드가 표시된다 | "출력 설정", "화면 비율", "해상도" 라벨과 선택지(4:3 가로, 4:3 세로, 원본, FHD, HD)가 표시되는지 확인 |
| 테스트 | 검증 내용 |
|---|---|
| 새로 만들기 -> 모션포토 선택 화면 | "새로 만들기" 탭 후 홈 화면의 "출력 설정"이 사라지는지 확인. 권한 다이얼로그가 뜨면 자동 제어 불가이므로, 화면이 바뀐 것만 최소 검증 |
| 내 무비 갤러리 -> 갤러리 화면 | "내 무비 갤러리" 탭 후 갤러리 화면으로 이동하는지 확인 |
| 테스트 | 검증 내용 |
|---|---|
| 화면 비율을 4:3 세로로 변경 | "4:3 세로" 탭 후 해상도 표시(NxM 형태)가 변경되는지 확인 |
| 해상도를 FHD로 변경 | "FHD" 탭 후 "1440x1080" 텍스트가 표시되는지 확인 |
| 해상도를 HD로 변경 | "HD" 탭 후 "960x720" 텍스트가 표시되는지 확인 |
기본 타임아웃은 매우 짧다. 앱 초기화, 네트워크 요청, 무거운 처리가 있으면 충분한 타임아웃을 준다.
await tester.pumpAndSettle(
const Duration(seconds: 10),
);
Provider로 상태를 변경한 후에는 반드시 pump()를 호출하여 UI가 리빌드되도록 해야 한다.
await tester.tap(find.text('옵션'));
// 상태 변경 후 UI 갱신 대기
await tester.pumpAndSettle();
권한 요청, 파일 선택기(SAF), 공유 시트 등은 OS 수준 UI이므로 Flutter Integration Test로 제어할 수 없다.
adb shell pm grant로 권한 부여자동화된 테스트 파이프라인에서 실행할 수 있다.
Key('test_button')를 부여하면 find.byKey()로 안정적으로 찾을 수 있다. 텍스트가 변경되어도 테스트가 깨지지 않는다.IntegrationTestWidgetsFlutterBinding의 takeScreenshot()으로 실패 시 스크린샷을 저장할 수 있다.testWidgets에서 app.main()을 새로 호출하여 깨끗한 상태에서 시작한다.debugPrint()로 테스트 중간에 로그를 남길 수 있다.