[Flutter] 실전 E2E 테스트 환경 구축 가이드

2026-04-11 hit count image

Flutter 프로젝트에 integration_test 기반 E2E 테스트 환경을 구축하고, Firebase/GetX/SQLite를 사용하는 실제 앱에 적용하는 방법을 알아봅니다.

flutter

개요

Flutter에서 E2E(End-to-End) 테스트는 실제 디바이스나 에뮬레이터에서 앱을 실행하여 사용자 플로우를 검증하는 테스트입니다. 위젯 테스트가 개별 컴포넌트를 격리하여 테스트한다면, E2E 테스트는 네비게이션, DB 조회, 컨트롤러 초기화 등이 실제 환경에서 통합적으로 동작하는지 확인합니다.

이 글에서는 Firebase, GetX, SQLite를 사용하는 실제 프로젝트에 E2E 테스트를 적용한 경험을 바탕으로, 환경 구축부터 CI 연동까지의 과정을 정리합니다.

이전에 작성한 Flutter 통합 테스트 기본 가이드에서는 Flutter 2.x 기준으로 기본적인 설정 방법을 다루었습니다. 이 글은 Flutter 3.x 환경에서의 실전 적용 가이드입니다.

기존 가이드와의 차이점

Flutter 3.x에서는 E2E 테스트 설정이 크게 간소화되었습니다.

항목Flutter 2.x (기존)Flutter 3.x (현재)
드라이버 파일test_driver/integration_test.dart 필요불필요
Android 설정build.gradle 수정, MainActivityTest.java 생성불필요
iOS 설정Xcode에서 RunnerTests 타겟 수동 생성불필요
실행 명령어flutter driveflutter test integration_test

Flutter 3.x에서는 pubspec.yaml에 패키지를 추가하고, integration_test/ 디렉토리에 테스트 파일을 작성하면 바로 실행할 수 있습니다.

환경 구축

패키지 추가

pubspec.yamldev_dependenciesintegration_test를 추가합니다.

dev_dependencies:
  flutter_test:
    sdk: flutter
  integration_test:
    sdk: flutter

E2E 테스트에서 테스트 전용 DB를 사용한다면, assets에도 등록합니다.

flutter:
  assets:
    - assets/data.db
    - assets/test.db # E2E 테스트용

디렉토리 구조

integration_test/
  app_test.dart              # 단일 진입점 (모든 테스트 실행)
  helpers/
    test_app.dart            # 앱 부트스트랩
    mock_setup.dart          # 컨트롤러 모킹
    db_setup.dart            # DB 리셋
  flows/
    learning_flow.dart       # 학습 플로우 테스트
    review_flow.dart         # 퀴즈 플로우 테스트
    search_flow.dart         # 검색 플로우 테스트

주의: flows/ 디렉토리의 파일은 _test.dart로 끝나지 않도록 합니다. flutter test integration_test는 디렉토리 내의 _test.dart 파일을 자동으로 탐색하여 각각 별도 프로세스로 실행합니다. 즉 app_test.dartlearning_flow_test.dart가 모두 존재하면, 각 파일의 main()이 독립적으로 실행되어 Firebase 초기화, DB 복사, 컨트롤러 등록이 두 번 수행됩니다. 이로 인해 두 번째 프로세스에서 이미 점유된 리소스와 충돌하여 hang이 발생합니다. _test.dart 접미사를 제거하면 test runner의 자동 탐색 대상에서 제외되어, app_test.dart에서 명시적으로 import하여 호출할 때만 실행됩니다.

테스트 인프라 구현

실제 앱에 E2E 테스트를 적용할 때의 핵심은 외부 의존성 제어입니다. Firebase, 광고 SDK, 인앱 결제 등은 테스트 환경에서 적절히 모킹하거나 비활성화해야 합니다.

SQLite DB 리셋 헬퍼

SQLite를 사용하는 앱에서 E2E 테스트를 할 때, DB를 매번 초기 상태로 리셋해야 테스트 결과가 결정적(deterministic)입니다.

위젯 테스트에서는 로컬 파일시스템의 DB 파일을 File.copySync()로 직접 복사할 수 있지만, E2E 테스트는 실제 디바이스의 앱 샌드박스 안에서 실행됩니다. 따라서 rootBundle로 에셋을 읽어 앱의 documents 디렉토리에 복사하는 방식을 사용해야 합니다.

또한 위젯 테스트에서는 sqlite3의 동기 API(sqlite3.open())로 DB를 직접 열 수 있지만, E2E 테스트에서는 프로덕션 코드의 DB 초기화 로직을 그대로 호출합니다. 프로덕션 코드의 init()은 내부적으로 DB 파일 존재 여부를 확인하고, 테이블 생성이나 컬럼 추가 같은 마이그레이션을 수행합니다. 프로덕션 코드를 재사용하면 마이그레이션 누락 걱정이 없습니다.

// integration_test/helpers/db_setup.dart
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import 'package:your_app/helpers/db_helper.dart';

Future<void> resetDatabase() async {
  final dbHelper = DBHelper();

  // 1. 기존 DB 연결 해제
  dbHelper.dispose();

  // 2. DB 경로 확인 및 기존 파일 삭제 (WAL/SHM 등 보조 파일 포함)
  final dbPath = await dbHelper.getDBPath();
  final basePath = p.dirname(dbPath);
  await dbHelper.deleteFileWithAuxiliary('$basePath/data.db');

  // 3. 에셋의 test.db를 앱 documents 디렉토리에 data.db로 복사
  final data = await rootBundle.load('assets/test.db');
  final dir = Directory(basePath);
  if (!dir.existsSync()) {
    await dir.create(recursive: true);
  }
  await File('$basePath/data.db').writeAsBytes(data.buffer.asUint8List());

  // 4. DB 초기화 (프로덕션 코드의 마이그레이션 로직 재사용)
  await dbHelper.init();
}

resetDatabase() 함수는 아래 테스트 앱 부트스트랩에서 initTestApp() 안에서 호출됩니다.

컨트롤러 모킹

E2E 테스트는 실제 디바이스에서 실행되므로, 네이티브 SDK를 호출하는 컨트롤러는 mock으로 교체해야 합니다. 예를 들어 광고 SDK(MobileAds), 알림(FlutterLocalNotifications), 인앱 결제(InAppPurchase) 등이 테스트 중에 호출되면 권한 요청 다이얼로그가 표시되거나 크래시가 발생할 수 있습니다.

GetX를 사용하는 경우, 컨트롤러의 onInit()에서 네이티브 SDK를 호출하는 경우가 많습니다. 이를 방지하려면 해당 컨트롤러를 상속하고 onInit()을 override하여 SDK 호출을 건너뛰도록 합니다.

// integration_test/helpers/mock_setup.dart

// 광고 SDK 호출을 방지하는 mock
// 원래 BannerController.onInit()이 MobileAds.instance.setAppMuted(true)를 호출함
class _MockBannerController extends BannerController {
  _MockBannerController() : super(isRectangle: true);

  @override
  // ignore: must_call_super
  void onInit() {}
}

// 알림 권한 요청을 방지하는 mock
// 원래 NotificationController.onInit()이 시스템 알림 권한을 요청함
class _MockNotificationController extends NotificationController {
  @override
  // ignore: must_call_super
  void onInit() {}
}

// ignore: must_call_super는 Dart analyzer의 경고를 무시하는 주석입니다. onInit()@mustCallSuper가 선언되어 있어 super를 호출하지 않으면 경고가 발생하지만, E2E 테스트에서는 의도적으로 생략합니다.

컨트롤러 등록 시 알아두어야 할 포인트:

  1. GetX의 Get.put() 중복 등록 동작을 활용합니다. GetX는 이미 등록된 타입의 인스턴스가 있으면, Get.put()을 다시 호출해도 새 인스턴스를 생성하지 않고 기존 것을 반환합니다. 따라서 앱의 Binding 코드(Get.put(MyController()))가 실행되기 전에 mock 컨트롤러를 먼저 Get.put()으로 등록하면, Binding이 실행될 때 기존 mock이 그대로 사용됩니다.

  2. 유료 기능 플래그를 활용하여 광고를 비활성화합니다. 인앱 결제로 광고를 제거하는 앱이라면, 테스트에서 유료 사용자 상태를 강제로 설정하여 광고 위젯 자체가 렌더링되지 않도록 할 수 있습니다. 이렇게 하면 MobileAds SDK 초기화 없이도 앱이 정상 동작합니다. 인앱 결제 플랫폼 인터페이스도 Fake 구현으로 교체하여 스토어 호출을 차단해야 합니다.

  3. SharedPreferences로 다이얼로그를 사전 비활성화합니다. 튜토리얼, 앱 리뷰 요청 등의 다이얼로그는 “이미 본 적 있음” 플래그를 SharedPreferences에 저장하는 경우가 많습니다. 이 플래그를 미리 true로 설정하면 다이얼로그가 표시되지 않습니다.

SharedPreferences.setMockInitialValues({
  'tutorialShown': true,
  'reviewDialogShown': true,
});

테스트 앱 부트스트랩

위에서 만든 헬퍼들을 하나의 initTestApp() 함수로 모아서 실행시킬 수 있도록 합니다. 이 함수는 테스트 실행 전에 한 번만 호출되어 앱의 실행 환경을 준비합니다.

// integration_test/helpers/test_app.dart
Future<void> initTestApp() async {
  // 1. Firebase 초기화 (실제 디바이스에서 필요)
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  // 2. 테스트 모드 설정 (Firebase 이벤트 전송 차단)
  CrashlyticsHelper.setTestMode(true);
  AnalyticsHelper.setTestMode(true);
  Get.testMode = true;

  // 3. 환경 변수 설정 (서버 URL, 언어, 앱 이름 등을 테스트용 값으로 교체)
  _setTestEnv();

  // 4. DB 리셋 (test.db → data.db 복사 + 마이그레이션)
  await resetDatabase();

  // 5. Mock 컨트롤러 등록
  await registerMockControllers();
}

Firebase는 실제로 초기화하되, test mode 플래그로 이벤트 전송을 차단합니다. 디버그 빌드의 Firebase 설정이 사용되므로 프로덕션 데이터에 영향을 주지 않습니다.

테스트 코드 작성

단일 진입점 구조

flutter test integration_test는 각 _test.dart 파일을 별도 프로세스로 실행합니다. 여러 파일에서 각각 initTestApp()을 호출하면 Firebase 재초기화, DB 파일 충돌 등으로 hang이 발생합니다.

해결: app_test.dart를 단일 진입점으로 하고, 개별 플로우 테스트는 함수로 export하여 import합니다.

// integration_test/app_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:your_app/main.dart';

import 'flows/learning_flow.dart';
import 'flows/review_flow.dart';
import 'flows/search_flow.dart';
import 'helpers/test_app.dart';

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  setUpAll(() async {
    await initTestApp();
  });

  group('Smoke test', () {
    testWidgets('앱이 정상적으로 시작된다', (tester) async {
      await tester.pumpWidget(const MyApp());
      await tester.pumpAndSettle();
      // 첫 화면의 위젯이 렌더링되는지 확인
      expect(find.byType(HomeScreen), findsOneWidget);
    });
  });

  learningFlowTests();
  reviewFlowTests();
  searchFlowTests();
}

각 flow 파일은 main() 없이 함수만 export합니다.

// integration_test/flows/learning_flow.dart
void learningFlowTests() {
  group('학습 플로우', () {
    testWidgets('목록 → 상세 화면 네비게이션', (tester) async {
      await tester.pumpWidget(const MyApp());
      await tester.pumpAndSettle();
      // ... 테스트 코드
    });
  });
}

Smoke 테스트

가장 기본적인 테스트로, 앱이 크래시 없이 시작되고 첫 화면이 렌더링되는지 확인합니다.

testWidgets('앱이 정상적으로 시작된다', (tester) async {
  await tester.pumpWidget(const MyApp());
  await tester.pumpAndSettle();
  expect(find.byType(HomeScreen), findsOneWidget);
});

이 테스트만으로도 Firebase 초기화, DB 로딩, 상태 관리 컨트롤러 등록, 위젯 렌더링이 모두 정상 동작하는지 검증됩니다.

네비게이션 플로우 테스트

실제 UI 탭으로 화면 간 이동을 검증합니다.

testWidgets('목록 → 상세 → 하위 상세 네비게이션', (tester) async {
  await tester.pumpWidget(const MyApp());
  await tester.pumpAndSettle();

  // 목록 항목 탭 → 상세 화면
  await tester.tap(find.text('항목 1'));
  await tester.pumpAndSettle();
  expect(find.byType(DetailScreen), findsOneWidget);

  // 하위 항목 탭 → 하위 상세 화면
  await tester.tap(find.byType(SubItem).first);
  await tester.pumpAndSettle();
  expect(find.text('하위 상세'), findsOneWidget);
});

Get.toNamed()으로 프로그래밍 방식으로 이동하는 대신 실제 UI 탭을 사용합니다. 이렇게 하면 Binding, Arguments 전달, Controller 초기화가 모두 자연스럽게 검증됩니다.

ExpansionTile 등 펼치기/접기 위젯을 테스트할 때는 펼쳐진 위젯의 탭 위치 문제를 참고하세요.

검색 플로우 테스트

텍스트 입력과 비동기 검색 결과를 검증합니다.

testWidgets('검색바 → 키워드 입력 → 결과 표시', (tester) async {
  await tester.pumpWidget(const MyApp());
  await tester.pumpAndSettle();

  // 검색바 탭 → 검색 화면으로 이동
  await tester.tap(find.byType(SearchBar));
  await tester.pumpAndSettle();

  // 키워드 입력 (debounce가 있어도 pumpAndSettle이 처리)
  await tester.enterText(find.byType(TextField), '검색어');
  await tester.pumpAndSettle();

  // 검색 결과 확인
  expect(find.byType(SearchResultItem), findsWidgets);
});

E2E 테스트는 실제 시간이 흐르므로 debounce timer가 자연스럽게 만료됩니다. pumpAndSettle()은 이후 UI가 안정될 때까지 대기합니다. 따라서 별도의 대기 로직이 필요 없습니다.

실행 방법

이 글에서는 Android 에뮬레이터 기준으로 설명합니다. iOS 시뮬레이터에서도 flutter test integration_test로 동일하게 실행 가능합니다.

로컬 실행

에뮬레이터가 연결된 상태에서 다음 명령어로 실행합니다.

flutter test integration_test/app_test.dart -d <device-id>

또는 bin/ 디렉토리에 Dart 스크립트를 작성하여 에뮬레이터 자동 감지, 자동 시작, 이전 앱 강제 종료를 자동화할 수 있습니다.

// bin/e2e_test.dart
import 'dart:io';

Future<void> main(List<String> args) async {
  // 에뮬레이터 자동 감지 (flutter devices --machine 출력을 파싱)
  String? deviceId = await _findRunningEmulator();
  if (deviceId == null) {
    // 에뮬레이터 자동 시작 (flutter emulators --launch 사용)
    deviceId = await _startAndroidEmulator();
  }

  // 이전 테스트의 앱 프로세스 강제 종료 (hang 방지)
  if (deviceId != null) {
    await Process.run('adb', [
      '-s', deviceId, 'shell', 'am', 'force-stop', 'com.example.app',
    ]);
  }

  // E2E 테스트 실행
  final testProcess = await Process.start('flutter', [
    'test', 'integration_test', '-d', deviceId!,
    ...args,
  ], mode: ProcessStartMode.inheritStdio);

  exit(await testProcess.exitCode);
}

// _findRunningEmulator, _startAndroidEmulator의 구현은
// flutter devices --machine의 JSON 출력 파싱,
// flutter emulators --launch 호출 등으로 구성합니다.
dart run bin/e2e_test.dart

CI (GitHub Actions)

workflow_dispatch(수동 트리거) 또는 PR 라벨로 실행하는 워크플로우를 구성합니다. E2E 테스트는 실행 시간이 길고 비용이 크므로, 매 PR마다 자동 실행하지 않고 필요할 때만 수동으로 트리거하는 것을 권장합니다.

name: E2E Test
on:
  workflow_dispatch:
  pull_request:
    types: [labeled]
jobs:
  e2e_test:
    name: E2E Test
    if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'e2e-test'
    runs-on: ubuntu-latest
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-java@v4
        with:
          distribution: 'temurin'
          java-version: '17'
      - name: Install dependencies
        uses: ./.github/actions/install_dependencies
      - name: Enable KVM
        run: |
          echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
            | sudo tee /etc/udev/rules.d/99-kvm4all.rules
          sudo udevadm control --reload-rules
          sudo udevadm trigger --name-match=kvm
      - name: Accept Android SDK licenses
        run: yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
      - name: Cache Gradle
        uses: actions/cache@v4
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
          restore-keys: gradle-${{ runner.os }}-
      - name: Pre-build APK
        run: flutter build apk --debug
      - name: Run E2E tests
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 34
          target: google_apis
          arch: x86_64
          emulator-options: >-
            -no-window -gpu swiftshader_indirect -noaudio
            -no-boot-anim -camera-back none
          disable-animations: true
          emulator-boot-timeout: 300
          script: flutter test integration_test/app_test.dart

주요 포인트:

  • KVM 활성화: Linux runner에서 에뮬레이터의 하드웨어 가속에 필요합니다.
  • SDK 라이센스 동의: 시스템 이미지를 처음 설치할 때 라이센스 동의 프롬프트가 표시되어 CI가 멈출 수 있습니다. yes |로 자동 동의합니다.
  • Gradle 캐시: Flutter 앱의 첫 Gradle 빌드는 CI 환경(2 vCPU)에서 35분 이상 소요될 수 있습니다. 캐시로 2회차부터 대폭 단축됩니다.
  • Pre-build APK: 에뮬레이터 실행과 Gradle 빌드를 동시에 하면 리소스(CPU/메모리)가 부족할 수 있습니다. APK를 먼저 빌드하여 에뮬레이터 단계에서는 테스트 실행만 하도록 합니다.
  • target: google_apis: Firebase를 사용하는 앱은 Google Play Services가 포함된 시스템 이미지가 필요합니다.

트러블슈팅

pumpAndSettle이 멈추는 경우

pumpAndSettle()은 모든 프레임과 애니메이션이 멈출 때까지 기다립니다. 로딩 스피너(CircularProgressIndicator)처럼 무한 반복 애니메이션이 있거나, 활성 Timer/Stream이 계속 프레임을 트리거하면 영원히 대기합니다.

해결: 비동기 데이터 로딩을 기다려야 하는 경우, pumpAndSettle() 대신 폴링 루프를 사용합니다.

// 특정 위젯이 나타날 때까지 최대 10초 대기
for (var i = 0; i < 20; i++) {
  if (find.byType(AnswerButton).evaluate().isNotEmpty) break;
  await tester.pump(const Duration(milliseconds: 500));
}

다이얼로그가 탭을 차단하는 경우

pumpAndSettle()Future.delayed 같은 비동기 타이머도 모두 처리합니다. 따라서 “300ms 후 다이얼로그 표시” 같은 코드가 있으면, pumpAndSettle() 완료 시점에 이미 다이얼로그가 화면을 가리고 있을 수 있습니다.

다이얼로그가 원인인지 확인하려면 테스트 실패 로그에서 RenderAbsorbPointer를 찾아보세요. 이 위젯은 다이얼로그의 배경 오버레이로, 뒤의 위젯에 대한 모든 탭을 차단합니다.

해결: SharedPreferences 플래그로 다이얼로그 자체를 비활성화하는 것이 가장 안정적입니다. 그것이 어렵다면, 다이얼로그를 닫은 후 다음 동작을 진행합니다.

// 다이얼로그가 표시되었다면 닫기 버튼 탭
final closeButton = find.text('닫기');
if (closeButton.evaluate().isNotEmpty) {
  await tester.tap(closeButton);
  await tester.pumpAndSettle();
}

펼쳐진 위젯의 탭 위치 문제

tester.tap()은 위젯의 중앙을 탭합니다. ExpansionTile이 접힌 상태에서는 중앙이 헤더 영역이지만, 펼쳐진 상태에서는 위젯의 높이가 커져서 중앙이 콘텐츠 영역으로 이동합니다. 콘텐츠 영역을 탭해도 ExpansionTile은 접히지 않습니다.

해결: 접기 동작에는 Icons.expand_less 같은 접기 아이콘을 직접 탭합니다.

// 펼치기 — 접힌 상태에서 위젯 중앙이 헤더에 위치하므로 정상 동작
await tester.tap(find.byType(MyExpansionWidget).first);

// 접기 — 펼쳐진 상태에서는 아이콘을 직접 탭
await tester.tap(find.byIcon(Icons.expand_less).first);

에뮬레이터 재실행 시 hang

E2E 테스트를 실행한 후 에뮬레이터에서 앱을 종료하지 않고 바로 다시 실행하면, 앱 설치/시작 단계에서 hang이 발생할 수 있습니다. 이전 앱 프로세스가 디바이스에서 아직 실행 중이기 때문입니다.

해결: 테스트 실행 전에 앱을 강제 종료합니다.

adb -s emulator-5554 shell am force-stop com.example.app

위의 로컬 실행 스크립트에서 이 작업을 자동화할 수 있습니다.

정리

Flutter 3.x에서의 E2E 테스트 환경 구축은 프레임워크 차원에서 많이 간소화되었지만, 실제 프로젝트에 적용할 때는 외부 의존성 처리가 핵심입니다.

요약:

  • integration_test 패키지 추가만으로 기본 설정 완료
  • Firebase, 광고, 인앱 결제 등은 테스트 전용 엔트리포인트에서 모킹
  • 모든 테스트를 단일 app_test.dart에서 실행하여 초기화 중복 방지
  • pumpAndSettle()의 동작을 이해하고 다이얼로그/애니메이션에 대응
  • CI에서는 Gradle 캐시와 Pre-build가 필수

제 블로그가 도움이 되셨나요? 하단의 댓글을 달아주시면 저에게 큰 힘이 됩니다!

앱 홍보

책 홍보

블로그를 운영하면서 좋은 기회가 생겨 책을 출판하게 되었습니다.

아래 링크를 통해 제가 쓴 책을 구매하실 수 있습니다.
많은 분들에게 도움이 되면 좋겠네요.



SHARE
Twitter Facebook RSS