Thursday, June 29, 2023

실용적 Flutter E2E 테스트: `flutter_driver` 심층 분석

도입: 왜 End-to-End 테스트가 중요한가?

현대의 애플리케이션 개발 환경은 그 어느 때보다 복잡하고 빠릅니다. Flutter와 같은 크로스플랫폼 프레임워크는 단일 코드베이스로 iOS와 Android 모두를 타겟할 수 있게 해주어 개발 생산성을 극적으로 향상시켰습니다. 하지만 빠른 개발 속도와 코드 변경 주기는 애플리케이션의 안정성을 위협하는 양날의 검이 될 수 있습니다. 사소한 코드 수정이 전혀 예상치 못한 곳에서 버그를 발생시키고, 이는 곧바로 사용자 경험의 저하로 이어집니다.

이러한 문제를 해결하기 위해 우리는 '테스트'를 작성합니다. 특히, 사용자의 관점에서 애플리케이션의 전체 흐름을 검증하는 **End-to-End(E2E) 테스트**는 최종 제품의 품질을 보증하는 데 있어 필수적인 과정입니다. E2E 테스트는 사용자가 앱을 처음 실행하는 순간부터 로그인, 데이터 조회, 기능 사용, 로그아웃에 이르기까지 전체 시나리오가 올바르게 동작하는지 확인합니다. 이는 개별 함수의 논리를 검증하는 유닛 테스트나 단일 위젯의 UI를 확인하는 위젯 테스트만으로는 발견하기 어려운 통합적인 문제들을 찾아내는 데 매우 효과적입니다.

이 글에서는 Flutter 생태계에서 E2E 테스트를 자동화하는 강력한 도구인 `flutter_driver`에 대해 심도 있게 다룹니다. 단순히 "버튼을 누르고 텍스트가 바뀌는지 확인하는" 수준을 넘어, `flutter_driver`의 동작 원리를 이해하고, 실제 프로젝트에서 마주할 수 있는 다양한 시나리오(스크롤, 폼 입력, 비동기 처리 등)에 대응하는 방법을 구체적인 코드 예시와 함께 살펴볼 것입니다. 또한, 테스트 코드의 유지보수성을 높이는 방법과 CI/CD 파이프라인에 통합하여 진정한 의미의 자동화된 품질 보증 시스템을 구축하는 과정까지 안내할 것입니다.

1장: Flutter 테스트의 세계와 `flutter_driver`의 위치

`flutter_driver`를 제대로 이해하기 위해서는 먼저 Flutter의 테스트 전략 전반을 조망할 필요가 있습니다. Flutter는 개발자가 다양한 수준의 테스트를 작성할 수 있도록 체계적인 테스트 환경을 제공합니다.

1.1. 테스트 피라미드: 유닛, 위젯, 그리고 통합 테스트

소프트웨어 테스트 분야에서는 흔히 '테스트 피라미드'라는 개념을 사용합니다. 이는 견고하고 효율적인 테스트 스위트를 구축하기 위한 전략적 모델입니다.

Testing Pyramid (이미지 출처: Google Testing Blog)
  • 유닛 테스트 (Unit Tests): 피라미드의 가장 아래 넓은 부분을 차지하며, 가장 빠르고 작성하기 쉽습니다. 단일 함수, 메소드 또는 클래스의 동작을 독립적으로 검증합니다. 외부 의존성이 거의 없으며, 코드의 논리적 정확성을 보장하는 데 중점을 둡니다.
  • 위젯 테스트 (Widget Tests): Flutter의 독특하고 강력한 테스트 유형입니다. 단일 위젯 또는 여러 위젯의 조합이 예상대로 렌더링되고 상호작용하는지 테스트합니다. 테스트 환경에서 실제 UI를 렌더링하지 않고 가상으로 위젯 트리를 빌드하므로 유닛 테스트만큼 빠릅니다.
  • 통합 테스트 (Integration Tests): 피라미드의 최상단에 위치하며, E2E 테스트라고도 불립니다. 실제 디바이스나 에뮬레이터에서 전체 애플리케이션을 실행하여 여러 부분(UI, 서비스, 데이터베이스, 네트워크 등)이 함께 올바르게 동작하는지 검증합니다. `flutter_driver`가 바로 이 영역을 담당합니다. 실행 속도가 가장 느리고 작성 비용이 높지만, 사용자 관점에서 최종적인 품질을 보증하는 가장 확실한 방법입니다.

성공적인 테스트 전략은 이 세 가지 유형의 테스트를 균형 있게 조합하는 것입니다. `flutter_driver`는 이 피라미드의 정점에서 애플리케이션 전체의 무결성을 지키는 수문장 역할을 합니다.

1.2. `flutter_driver`란 무엇인가? 단순한 테스트 도구를 넘어서

`flutter_driver`는 Flutter 애플리케이션의 UI를 외부에서 제어하고 상태를 검증하기 위한 테스트 자동화 프레임워크입니다. 사용자가 화면을 탭하고, 스크롤하고, 텍스트를 입력하는 모든 행동을 코드로 시뮬레이션할 수 있습니다.

`flutter_driver`는 다음과 같은 특징을 가집니다:

  • Out-of-Process 실행: 테스트 스크립트는 테스트 대상 앱과 완전히 다른 프로세스에서 실행됩니다. 이는 실제 사용자와 앱의 상호작용을 가장 유사하게 모방하며, 앱의 내부 상태에 직접 접근하지 않아 테스트의 신뢰도를 높입니다.
  • 플랫폼 독립성: Dart로 작성된 단일 테스트 코드로 iOS와 Android 양쪽 플랫폼에서 모두 실행할 수 있습니다. 이는 Flutter의 크로스플랫폼 철학과 일치합니다.
  • 풍부한 API: 특정 위젯을 찾고(Finding), 상호작용하며(Interacting), 상태를 검증(Verifying)하기 위한 다양한 API를 제공합니다. 스크린샷, 성능 추적과 같은 고급 기능도 지원합니다.

1.3. 동작 원리: 앱과 테스트 스크립트의 분리

`flutter_driver`의 핵심은 '분리'에 있습니다. 테스트를 실행하면 두 개의 주요 구성 요소가 동작합니다.

  1. 계측된 앱 (Instrumented App): 우리가 테스트하고자 하는 실제 Flutter 앱입니다. 다만, `flutter_driver`와 통신할 수 있도록 `VM Service Extension`이라는 특별한 통신 채널이 활성화된 상태로 빌드됩니다.
  2. 테스트 스크립트 (Test Script): 별도의 Dart 프로세스에서 실행되는 코드입니다. 이 스크립트는 `FlutterDriver` 인스턴스를 통해 앱에 명령을 보냅니다. 예를 들어 "ID가 'login_button'인 버튼을 찾아 탭해라"와 같은 명령을 내릴 수 있습니다.

이 둘 사이의 통신은 JSON-RPC(Remote Procedure Call) 프로토콜을 통해 이루어집니다. 테스트 스크립트가 `driver.tap(finder)`와 같은 메소드를 호출하면, 이 호출은 직렬화 가능한 형태로 변환되어 VM Service Extension을 통해 앱으로 전송됩니다. 앱은 이 메시지를 받아 해석하고, 실제 위젯 트리에서 해당 위젯을 찾아 탭 이벤트를 발생시킨 후, 그 결과를 다시 테스트 스크립트로 반환합니다. 이러한 비동기 통신 방식 덕분에 테스트 스크립트는 앱의 동작을 정밀하게 제어하고 결과를 확인할 수 있습니다.

2장: `flutter_driver` 테스트 환경 구축하기

이론을 알았으니 이제 직접 환경을 구축해볼 차례입니다. `flutter_driver` 테스트를 시작하기 위한 초기 설정은 매우 간단하며, 몇 가지 규칙만 따르면 됩니다.

2.1. 의존성 추가: `pubspec.yaml` 설정

가장 먼저, 프로젝트의 `pubspec.yaml` 파일에 필요한 패키지를 추가해야 합니다.


# pubspec.yaml

...

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_driver:
    sdk: flutter
  test: any

...

`dev_dependencies` 섹션에 `flutter_driver`와 `test` 패키지를 추가합니다. 이들은 앱의 릴리즈 빌드에는 포함되지 않고 개발 및 테스트 과정에서만 사용됩니다. `flutter test`를 위해 기본으로 포함된 `flutter_test`도 그대로 둡니다. 의존성을 추가한 후에는 터미널에서 `flutter pub get` 명령을 실행하여 패키지를 다운로드합니다.

2.2. 필수 파일 구조: `test_driver` 디렉토리 생성

`flutter_driver` 테스트는 관례적으로 프로젝트 루트에 `test_driver`라는 디렉토리를 만들어 그 안에 관련 파일들을 위치시킵니다.

my_flutter_app/
├── lib/
│   └── main.dart
├── test/
│   └── widget_test.dart
├── test_driver/
│   ├── app.dart
│   └── app_test.dart
└── pubspec.yaml

`test_driver` 디렉토리 안에는 최소 두 개의 파일이 필요합니다.

  • `app.dart`: 테스트를 위해 계측된 앱의 진입점(entry point) 파일입니다.
  • `app_test.dart`: 실제 테스트 로직을 담고 있는 테스트 스크립트 파일입니다.

파일 이름은 `app`이 아니어도 괜찮지만, 보통 `.dart`와 `_test.dart` 형식으로 쌍을 이루도록 짓는 것이 일반적입니다.

2.3. 계측된 앱(Instrumented App) 작성: `app.dart`

`test_driver/app.dart` 파일의 역할은 `flutter_driver` 확장 기능을 활성화하고, 실제 앱을 실행하는 것입니다.


// test_driver/app.dart

import 'package:flutter_driver/driver_extension.dart';
import 'package:my_flutter_app/main.dart' as app;

void main() {
  // 1. Flutter Driver 확장 기능을 활성화합니다.
  enableFlutterDriverExtension();

  // 2. 실제 앱의 main 함수를 호출하여 앱을 실행합니다.
  //    'as app'을 사용하여 네임스페이스 충돌을 방지합니다.
  app.main();
}

여기서 가장 중요한 부분은 `enableFlutterDriverExtension()` 호출입니다. 이 함수가 바로 앞서 설명한 VM Service Extension을 활성화하여 테스트 스크립트가 앱과 통신할 수 있는 다리를 놓아주는 역할을 합니다. 그 후, 기존 `lib/main.dart`의 `main` 함수를 호출하여 평소처럼 앱을 실행시킵니다.

2.4. 테스트 스크립트 작성: `app_test.dart`

`test_driver/app_test.dart`는 테스트의 두뇌 역할을 하는 곳입니다. 기본적인 구조는 다음과 같습니다.


// test_driver/app_test.dart

import 'package:flutter_driver/flutter_driver.dart';
import 'package:test/test.dart';

void main() {
  group('Counter App Test', () {
    FlutterDriver? driver;

    // 모든 테스트가 실행되기 전에 한 번만 호출됩니다.
    // 앱에 연결하고 드라이버 인스턴스를 설정합니다.
    setUpAll(() async {
      driver = await FlutterDriver.connect();
    });

    // 모든 테스트가 완료된 후에 한 번만 호출됩니다.
    // 드라이버 연결을 종료합니다.
    tearDownAll(() async {
      if (driver != null) {
        await driver!.close();
      }
    });

    // 여기에 개별 테스트 케이스들을 작성합니다.
    test('starts at 0', () async {
      // 테스트 로직
    });
    
    test('increments the counter', () async {
      // 테스트 로직
    });
  });
}

`test` 패키지에서 제공하는 `group`, `setUpAll`, `tearDownAll`, `test` 함수를 사용하여 테스트 스위트를 구조화합니다.

  • `setUpAll`: 모든 테스트 케이스가 실행되기 전에 단 한 번 실행됩니다. `FlutterDriver.connect()`를 호출하여 실행 중인 앱에 연결하고, `driver` 변수에 인스턴스를 할당합니다. 이 연결 과정이 성공해야 이후의 테스트를 진행할 수 있습니다.
  • `tearDownAll`: 모든 테스트가 끝난 후 단 한 번 실행됩니다. `driver.close()`를 호출하여 앱과의 연결을 안전하게 종료합니다. 이를 통해 테스트 프로세스가 정상적으로 마무리됩니다.
  • `test`: 개별 테스트 케이스를 정의하는 함수입니다. 첫 번째 인자로는 테스트의 목적을 설명하는 문자열을, 두 번째 인자로는 비동기(`async`) 함수를 전달합니다.

이제 환경 설정이 완료되었습니다. 다음 장에서는 이 구조를 바탕으로 실제 테스트 로직을 채워 넣는 방법을 자세히 알아보겠습니다.

3장: 강력하고 안정적인 테스트 스크립트 작성 기법

기본적인 환경이 갖춰졌으니 이제 실제 사용자 시나리오를 자동화하는 방법을 배울 시간입니다. Flutter의 기본 카운터 앱을 예제로 다양한 상호작용을 테스트하는 코드를 작성해보겠습니다.

3.1. 기본 상호작용: 위젯 찾기, 탭, 텍스트 검증

E2E 테스트의 가장 기본적인 흐름은 '찾고(Find) -> 행동하고(Act) -> 검증하기(Verify)'입니다.

먼저 테스트 대상 위젯에 식별자를 부여하는 것이 좋습니다. `Key`를 사용하는 것이 가장 안정적이고 효율적인 방법입니다.


// lib/main.dart (일부)

// Text 위젯에 Key 추가
Text(
  '$_counter',
  key: const ValueKey('counter_text'), // 테스트를 위한 Key
  style: Theme.of(context).textTheme.headlineMedium,
),

// FloatingActionButton에 Key 추가
FloatingActionButton(
  key: const ValueKey('increment_button'), // 테스트를 위한 Key
  onPressed: _incrementCounter,
  tooltip: 'Increment',
  child: const Icon(Icons.add),
)

이제 이 `Key`를 사용하여 `app_test.dart`에서 테스트를 작성할 수 있습니다.


// test_driver/app_test.dart (일부)

test('increments the counter', () async {
  // 1. 찾기 (Find): ValueKey를 사용하여 위젯을 찾기 위한 Finder 생성
  final counterTextFinder = find.byValueKey('counter_text');
  final buttonFinder = find.byValueKey('increment_button');

  // 2. 검증 (Verify): 초기 카운터 텍스트가 '0'인지 확인
  expect(await driver!.getText(counterTextFinder), "0");

  // 3. 행동 (Act): 버튼을 탭
  await driver!.tap(buttonFinder);

  // 4. 검증 (Verify): 탭 이후 카운터 텍스트가 '1'로 변경되었는지 확인
  expect(await driver!.getText(counterTextFinder), "1");

  // 추가로 여러 번 탭하는 시나리오도 테스트 가능
  await driver!.tap(buttonFinder);
  await driver!.tap(buttonFinder);
  
  expect(await driver!.getText(counterTextFinder), "3");
});
  • `find.byValueKey(key)`: `ValueKey`를 이용해 위젯을 찾기 위한 `SerializableFinder` 객체를 생성합니다. 이 외에도 `find.byText()`, `find.byTooltip()`, `find.byType()` 등 다양한 Finder가 존재합니다.
  • `driver.getText(finder)`: 해당 Finder로 찾은 위젯의 텍스트를 `String`으로 반환합니다. 이 작업은 앱과의 통신이 필요하므로 `await` 키워드를 사용해야 합니다.
  • `expect(actual, expected)`: `test` 패키지의 검증 함수입니다. `actual` 값이 `expected` 값과 같은지 비교합니다.
  • `driver.tap(finder)`: 해당 Finder로 찾은 위젯을 탭합니다.

3.2. 스크롤 및 목록 처리: 화면 밖 위젯과의 상호작용

앱에는 한 화면에 보이지 않는 긴 목록이 있는 경우가 많습니다. `flutter_driver`는 이런 상황을 처리하기 위한 스크롤 관련 API를 제공합니다.

예를 들어, 100개의 아이템을 가진 `ListView`가 있고, 우리는 75번째 아이템을 찾아 탭해야 한다고 가정해봅시다.


// lib/main.dart (ListView 예시)
// ListView.builder(
//   itemCount: 100,
//   itemBuilder: (context, index) {
//     return ListTile(
//       key: ValueKey('item_${index}_text'),
//       title: Text('Item $index'),
//     );
//   },
// )

이 목록을 테스트하는 코드는 다음과 같습니다.


// test_driver/app_test.dart

test('scrolls to find an item and taps it', () async {
  // 1. 찾고자 하는 아이템과 스크롤 가능한 뷰의 Finder를 정의
  final itemFinder = find.byValueKey('item_75_text');
  final listFinder = find.byType('ListView'); // 스크롤할 위젯의 타입으로 찾기

  // 2. scrollUntilVisible API 사용
  // listFinder를 스크롤하여 itemFinder가 화면에 나타날 때까지 반복
  // dyScroll: 한 번에 스크롤할 픽셀 양 (음수: 아래로, 양수: 위로)
  await driver!.scrollUntilVisible(
    listFinder,
    itemFinder,
    dyScroll: -300.0, // 아래로 300px씩 스크롤
    timeout: const Duration(seconds: 10), // 10초 내에 못 찾으면 타임아웃
  );

  // 3. 화면에 나타난 아이템의 텍스트를 검증
  expect(
    await driver!.getText(itemFinder),
    'Item 75',
  );

  // 4. 해당 아이템을 탭
  await driver!.tap(itemFinder);

  // (탭 이후의 동작을 검증하는 코드...)
});

`driver.scrollUntilVisible`은 매우 강력한 API입니다. 지정된 위젯이 화면에 나타날 때까지 스크롤을 자동으로 수행해주므로, 화면 크기나 해상도에 상관없이 안정적인 테스트를 작성할 수 있게 해줍니다.

3.3. 폼 및 텍스트 입력 자동화

로그인이나 회원가입 폼처럼 사용자의 텍스트 입력이 필요한 기능도 쉽게 자동화할 수 있습니다.


// lib/main.dart (TextField 예시)
// TextField(
//   key: ValueKey('email_input'),
//   decoration: InputDecoration(labelText: 'Email'),
// ),
// TextField(
//   key: ValueKey('password_input'),
//   obscureText: true,
//   decoration: InputDecoration(labelText: 'Password'),
// ),
// ElevatedButton(
//   key: ValueKey('login_button'),
//   onPressed: () { ... },
//   child: Text('Login'),
// )

테스트 스크립트는 다음과 같이 작성합니다.


// test_driver/app_test.dart

test('fills out login form and taps login', () async {
  final emailFieldFinder = find.byValueKey('email_input');
  final passwordFieldFinder = find.byValueKey('password_input');
  final loginButtonFinder = find.byValueKey('login_button');

  // 1. 이메일 필드를 탭하여 포커스를 줍니다.
  await driver!.tap(emailFieldFinder);
  
  // 2. 텍스트를 입력합니다.
  await driver!.enterText('user@example.com');

  // 3. 패스워드 필드를 탭합니다.
  await driver!.tap(passwordFieldFinder);

  // 4. 텍스트를 입력합니다.
  await driver!.enterText('strong-password-123');

  // 5. 입력된 텍스트를 다시 확인하여 검증할 수도 있습니다.
  //    (단, password 필드는 obscureText=true 이므로 getText가 동작하지 않을 수 있음)
  expect(await driver!.getText(emailFieldFinder), 'user@example.com');
  
  // 6. 로그인 버튼을 탭합니다.
  await driver!.tap(loginButtonFinder);

  // (로그인 성공 후 나타나는 환영 메시지 등을 검증하는 코드...)
});

`driver.enterText()` API를 사용하면 현재 포커스된 입력 필드에 텍스트를 입력할 수 있습니다. 따라서 특정 필드에 입력하기 전에 `driver.tap()`으로 해당 필드에 먼저 포커스를 맞춰주는 것이 중요합니다.

3.4. 비동기 작업 처리: 안정적인 테스트의 핵심

실제 앱은 네트워크 요청, 데이터베이스 접근 등 완료하는 데 시간이 걸리는 비동기 작업을 많이 수행합니다. 테스트 스크립트가 이런 대기 시간을 고려하지 않으면, UI가 갱신되기 전에 검증을 시도하여 테스트가 실패하는 'Flaky Test(변덕스러운 테스트)'가 발생하기 쉽습니다.

`flutter_driver`는 이런 상황을 위해 명시적으로 대기할 수 있는 `waitFor` API를 제공합니다.


// test_driver/app_test.dart

test('fetches data and displays it', () async {
  final buttonFinder = find.byValueKey('fetch_data_button');
  final loadingIndicatorFinder = find.byType('CircularProgressIndicator');
  final resultTextFinder = find.byValueKey('result_data_text');
  
  // 초기 상태: 로딩 인디케이터와 결과 텍스트가 없음
  await driver!.waitForAbsent(loadingIndicatorFinder);
  await driver!.waitForAbsent(resultTextFinder);

  // 데이터 로드 버튼 탭
  await driver!.tap(buttonFinder);
  
  // 로딩 인디케이터가 나타나는 것을 기다림
  await driver!.waitFor(loadingIndicatorFinder, timeout: const Duration(seconds: 3));

  // 로딩 인디케이터가 사라지는 것을 기다림 (데이터 로드 완료)
  await driver!.waitForAbsent(loadingIndicatorFinder, timeout: const Duration(seconds: 15));

  // 결과 텍스트가 화면에 나타나는 것을 기다리고, 내용 검증
  await driver!.waitFor(resultTextFinder);
  expect(await driver!.getText(resultTextFinder), 'Data Loaded Successfully');
});
  • `driver.waitFor(finder)`: 해당 `finder`로 위젯을 찾을 수 있을 때까지 지정된 시간(기본 5초) 동안 기다립니다. 위젯이 나타나면 테스트가 계속 진행됩니다.
  • `driver.waitForAbsent(finder)`: `waitFor`와 반대로, 해당 `finder`로 위젯을 찾을 수 *없을* 때까지 기다립니다. 로딩 인디케이터가 사라지거나, 다이얼로그가 닫히는 등의 상황을 검증할 때 유용합니다.

이러한 동기화 API를 적절히 사용하면 네트워크 지연이나 디바이스 성능에 관계없이 일관되게 통과하는 안정적인 테스트를 작성할 수 있습니다.

4장: 고급 활용법 및 유지보수성 향상

테스트 작성이 익숙해졌다면, 이제 테스트의 품질을 높이고 유지보수를 쉽게 만드는 고급 기법들을 살펴볼 차례입니다.

4.1. 디버깅의 핵심: 스크린샷 캡처

테스트가 실패했을 때, 어떤 화면에서 왜 실패했는지 파악하기 어려울 때가 많습니다. 특히 CI/CD 환경처럼 GUI 없이 실행되는 경우에는 더욱 그렇습니다. `flutter_driver`는 스크린샷 기능을 제공하여 이런 문제를 해결하는 데 큰 도움을 줍니다.


// test_driver/app_test.dart

import 'dart:io';

// ...

// tearDownAll 이전에 스크린샷을 저장할 디렉토리를 생성
setUpAll(() async {
  driver = await FlutterDriver.connect();
  Directory('screenshots').create();
});

test('takes screenshot on failure', () async {
  try {
    final nonExistentFinder = find.byValueKey('i_do_not_exist');
    await driver!.tap(nonExistentFinder);
  } catch (e) {
    // 테스트 실패 시 스크린샷 촬영
    final screenshotBytes = await driver!.screenshot();
    final file = File('screenshots/failure_screenshot.png');
    await file.writeAsBytes(screenshotBytes);
    
    // 에러를 다시 던져서 테스트를 실패로 표시
    rethrow;
  }
});

`driver.screenshot()`는 화면을 캡처하여 `List<int>` (바이트 배열) 형태로 반환합니다. 이를 `dart:io`의 `File` API를 사용해 이미지 파일로 저장할 수 있습니다. `try-catch` 구문을 활용하여 테스트가 실패하는 지점에서 스크린샷을 찍도록 구성하면, CI/CD 서버에서 테스트 실패 시 아티팩트(artifact)로 스크린샷을 저장하여 원인 분석을 용이하게 할 수 있습니다.

4.2. 성능 병목 찾기: 성능 프로파일링

`flutter_driver`는 단순히 기능의 정확성만 테스트하는 것을 넘어, 앱의 성능을 측정하는 데도 사용될 수 있습니다. 특정 작업(예: 긴 목록 스크롤, 복잡한 애니메이션 실행)의 성능을 프로파일링하여 프레임 드랍(Jank)이나 과도한 CPU/GPU 사용량을 파악할 수 있습니다.


test('measures scrolling performance', () async {
  final listFinder = find.byType('ListView');

  // 1. 성능 추적 시작
  // block: 추적할 작업을 담은 비동기 함수
  final summary = await driver!.traceAction(() async {
    // 10번 위아래로 격렬하게 스크롤
    for (int i = 0; i < 5; i++) {
      await driver!.scroll(listFinder, 0, -500, const Duration(milliseconds: 300));
      await driver!.scroll(listFinder, 0, 500, const Duration(milliseconds: 300));
    }
  },
  // timelineSummaryInto: 결과를 저장할 파일 경로
  // streams: 수집할 성능 데이터 종류 (기본값: 모든 스트림)
  streams: const [TimelineStream.dart, TimelineStream.embedder]);

  // 2. 수집된 성능 요약 데이터를 파일로 저장
  final summaryFile = File('performance_data/scrolling_summary.json');
  await summaryFile.writeAsString(summary.toJson());
});

`driver.traceAction`을 실행하면, 해당 블록 내의 동작이 실행되는 동안의 성능 데이터(Timeline)가 수집됩니다. 이 데이터는 JSON 형식으로 저장되며, `chrome://tracing`과 같은 프로파일링 도구에서 열어 프레임 렌더링 시간, GC(Garbage Collection) 이벤트 등을 시각적으로 분석할 수 있습니다. 이를 통해 성능 저하의 원인을 정밀하게 파악하고 개선할 수 있습니다.

4.3. 테스트 코드 재사용성 증대: 페이지 객체 모델(POM)

프로젝트가 커지면 테스트 코드도 복잡해집니다. 여러 테스트 케이스에서 동일한 위젯 Finder나 상호작용 로직이 반복적으로 사용되면, UI가 변경될 때마다 수많은 테스트 코드를 수정해야 하는 유지보수 지옥에 빠질 수 있습니다.

**페이지 객체 모델(Page Object Model, POM)**은 이러한 문제를 해결하는 디자인 패턴입니다. 앱의 각 화면(페이지)을 하나의 클래스로 추상화하고, 해당 화면의 위젯(Finder)과 상호작용(메소드)을 이 클래스 안에 캡슐화하는 방식입니다.


// test_driver/pages/login_page.dart

import 'package:flutter_driver/flutter_driver.dart';

class LoginPage {
  final FlutterDriver _driver;

  // 생성자를 통해 FlutterDriver 인스턴스를 주입받음
  LoginPage(this._driver);

  // Private Finder 정의
  final _emailField = find.byValueKey('email_input');
  final _passwordField = find.byValueKey('password_input');
  final _loginButton = find.byValueKey('login_button');

  // 페이지가 제공하는 행동(Action)을 메소드로 정의
  Future<void> enterEmail(String email) async {
    await _driver.tap(_emailField);
    await _driver.enterText(email);
  }
  
  Future<void> enterPassword(String password) async {
    await _driver.tap(_passwordField);
    await _driver.enterText(password);
  }

  // 행동 후 다른 페이지로 이동하는 경우, 해당 페이지 객체를 반환할 수 있음
  Future<HomePage> tapLoginButton() async {
    await _driver.tap(_loginButton);
    return HomePage(_driver); // 로그인 후 HomePage로 이동한다고 가정
  }
}

// test_driver/pages/home_page.dart (예시)
class HomePage {
  final FlutterDriver _driver;
  HomePage(this._driver);

  final _welcomeMessage = find.byValueKey('welcome_message');

  Future<String> getWelcomeMessage() {
    return _driver.getText(_welcomeMessage);
  }
}

이렇게 페이지 객체를 정의하고 나면, 테스트 스크립트는 훨씬 간결하고 가독성이 높아집니다.


// test_driver/app_test.dart

import 'pages/login_page.dart';

// ...

test('successful login navigates to home page', () async {
  // 1. LoginPage 객체 생성
  final loginPage = LoginPage(driver!);

  // 2. 페이지 객체의 메소드를 호출하여 테스트 시나리오 수행
  await loginPage.enterEmail('user@example.com');
  await loginPage.enterPassword('password');
  final homePage = await loginPage.tapLoginButton();

  // 3. 다음 페이지 객체를 통해 결과 검증
  expect(await homePage.getWelcomeMessage(), 'Welcome, user!');
});

이제 `email_input`의 Key가 변경되더라도 `LoginPage` 클래스 내부만 수정하면 되므로, 이 페이지를 사용하는 모든 테스트 코드는 변경할 필요가 없습니다. 이처럼 POM은 테스트 코드의 **추상화 수준을 높여 유지보수성과 재사용성을 극대화**합니다.

5장: CI/CD 연동 및 미래

테스트 자동화의 진정한 가치는 개발 파이프라인에 통합되어 사람의 개입 없이 지속적으로 실행될 때 발휘됩니다.

5.1. 자동화 파이프라인에 `flutter_driver` 통합하기

GitHub Actions, Jenkins, GitLab CI 등 대부분의 CI/CD 도구에서 `flutter_driver` 테스트를 실행할 수 있습니다. 핵심은 GUI가 없는 환경(Headless)에서 에뮬레이터를 실행하고, 특정 명령어로 테스트를 트리거하는 것입니다.

테스트를 실행하는 명령어는 다음과 같습니다.


flutter drive --target=test_driver/app.dart

`--target` 옵션으로 계측된 앱의 진입점 파일(`test_driver/app.dart`)을 지정해주면, Flutter Tool이 자동으로 해당 파일과 쌍을 이루는 테스트 스크립트(`test_driver/app_test.dart`)를 찾아 실행합니다.

GitHub Actions를 사용하는 워크플로우 예시입니다.


# .github/workflows/flutter_drive.yml

name: Flutter Driver CI

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  drive_test:
    runs-on: macos-latest # 에뮬레이터/시뮬레이터 실행을 위해 macOS 환경 사용
    steps:
    - uses: actions/checkout@v3
    - uses: actions/setup-java@v3
      with:
        distribution: 'zulu'
        java-version: '11'
    - uses: subosito/flutter-action@v2
      with:
        channel: 'stable'

    - name: Run Android emulator
      uses: reactivecircus/android-emulator-runner@v2
      with:
        api-level: 29
        script: flutter drive --target=test_driver/app.dart

    # (테스트 실패 시 스크린샷 등 아티팩트 업로드 단계 추가 가능)
    - name: Upload Screenshots on Failure
      if: failure()
      uses: actions/upload-artifact@v3
      with:
        name: failure-screenshots
        path: screenshots/

이 워크플로우는 코드가 `main` 브랜치에 푸시되거나 Pull Request가 생성될 때마다 자동으로 실행됩니다. `android-emulator-runner`와 같은 액션을 사용하여 가상 디바이스를 띄우고, 그 위에서 `flutter drive` 명령을 실행합니다. 이를 통해 모든 코드 변경이 기존 기능을 손상시키지 않았음을 자동으로 검증할 수 있습니다.

5.2. `integration_test`: 차세대 통합 테스트 프레임워크

Flutter 팀은 `flutter_driver`의 경험을 바탕으로 새로운 통합 테스트 패키지인 **`integration_test`**를 개발하여 공식적으로 권장하고 있습니다. `integration_test`는 `flutter_driver`의 장점을 계승하면서 몇 가지 중요한 개선점을 가지고 있습니다.

  • 통합된 API: `flutter_test` (위젯 테스트)와 거의 동일한 API를 사용합니다. 즉, 위젯 테스트를 작성하는 방식으로 실제 디바이스에서 실행되는 E2E 테스트를 작성할 수 있어 학습 곡선이 낮습니다.
  • 단일 프로세스 실행: 테스트 코드가 앱과 동일한 프로세스 내에서 실행됩니다. 이로 인해 `flutter_driver`의 RPC 통신 오버헤드가 없어 실행 속도가 더 빠르고 테스트 안정성이 높습니다.
  • Flutter Test와의 호환성: 기존 `flutter test` 명령어로 실행할 수 있으며, `flutter_driver`처럼 별도의 `drive` 명령어가 필요 없습니다.

`integration_test`는 사실상 `flutter_driver`의 다음 세대 기술이며, 새로운 프로젝트를 시작한다면 `integration_test` 사용을 적극적으로 고려하는 것이 좋습니다. 하지만 `flutter_driver`는 여전히 많은 프로젝트에서 사용되고 있고, 성능 프로파일링과 같은 일부 고급 기능은 `flutter_driver`가 더 강력한 측면을 가지고 있으므로, 그 원리와 사용법을 이해하는 것은 여전히 매우 중요합니다.

결론: 품질을 향한 투자

지금까지 우리는 `flutter_driver`를 사용하여 Flutter 앱의 End-to-End 테스트를 구축하는 여정을 함께했습니다. `flutter_driver`는 단순한 테스트 도구가 아니라, 사용자 경험을 코드로 시뮬레이션하고 애플리케이션의 전체적인 무결성을 보장하는 강력한 품질 보증 시스템입니다.

자동화된 E2E 테스트를 구축하는 것은 초기에는 시간과 노력이 드는 투자입니다. 하지만 이 투자는 개발 사이클이 반복될수록 엄청난 이자로 돌아옵니다. 개발자는 코드 변경에 대한 자신감을 얻고, 회귀 버그(Regression Bug)의 공포에서 벗어날 수 있으며, 수동 테스트에 쏟던 시간을 더 가치 있는 기능 개발에 집중할 수 있게 됩니다. 결국, 잘 만들어진 테스트 스위트는 더 빠르고, 더 안정적이며, 더 높은 품질의 제품을 사용자에게 전달하는 가장 확실한 방법입니다. `flutter_driver`를 통해 여러분의 Flutter 앱 품질을 한 단계 끌어올리시길 바랍니다.


0 개의 댓글:

Post a Comment