image

Overview

위 포스트에서는 Flutter E2E 자동화 테스트를 적용해보는 내용에 대해서 다루고 있습니다.

1. E2E(End to End) Test 란?

E2E(End-to-End) 테스트는 앱이 실제 사용 환경에서 예상대로 작동하는지 확인하기 위해 전체 시스템을 테스트하는 과정입니다. 이는 실제 기기와 서버를 사용하여 앱의 시작부터 끝까지(한 ‘End’에서 다른 ‘End’까지) 모든 기능을 검사합니다. 전통적으로는 수동으로 수행되었지만, 시간 소모가 크고 오류 가능성이 높은 단점이 있습니다.

이를 극복하기 위해, E2E 테스트의 자동화가 중요해졌습니다. 자동화는 테스트의 일관성을 높이고 시간을 절약하며, 오류 가능성을 줄여줍니다.

특히, 지속적인 통합 및 배포(CI/CD) 파이프라인에서 중요한 역할을 하며, 각 릴리스가 사용자가 요구하는 높은 품질과 신뢰성을 충족하도록 돕습니다.

Flutter와 같은 플랫폼에서는 integration_test 패키지를 통해 E2E 테스트를 자동화할 수 있으며, 이는 앱의 모든 부분이 실제 조건에서 정상적으로 작동하는지 확인하는 데 유용합니다.

E2E Test vs Integration Test vs Instrument Test

해당 포스트에서 E2E Test 와 Integration Test 용어를 혼재하고 있지만 둘은 다른 의미를 가지고 있습니다.

  • E2E Test: 앱의 전체적인 기능과 성능을 검증하는 테스트로, 사용자의 관점에서 앱의 동작을 검증합니다. 이는 앱의 시작부터 끝까지(Client 에서 Server까지) 모든 기능을 검사합니다.
  • Integration Test: 앱의 여러 부분이 함께 작동하는지 확인하는 테스트로, 서로 다른 부분들이 함께 잘 작동하는지 확인합니다. 이는 특정 기능이 다른 기능과 잘 통합되는지 확인하는 데 사용됩니다.

그리고 또 Instrument Test 라는 용어가 있습니다. 해당 테스트의 정의는 아래와 같습니다.

  • Instrument Test: 앱의 UI와 같은 인터페이스를 테스트하는 테스트로, 실제 디바이스나 에뮬레이터에서 실행되는 테스트를 의미합니다. 사용자 인터페이스와 애플리케이션의 통합된 부분이 예상대로 동작하는지 검증합니다. 주로 앱의 성능과 사용자 인터랙션을 포함한 전반적인 기능을 확인합니다.

해당 포스트에서 소개하는 테스트는 엄밀히 따지면 Instrument Test 라고 할 수 있으며 추가로 다른 종단(Server)의 동작 확인도 같이 커버가 되기 때문에 E2E Test 영역에도 속할 수 있습니다.

만약 서버를 Simulator 나 Mocking 을 통해서 테스트를 진행한다면 해당 테스트는 오로지 Instrument Test 에 속하게 됩니다.

flutter에서는 integration_test 이라는 패키지 명을 사용하고 있어서 혹시 오해가 있을 수 있으니 참고하시기 바랍니다.

E2E 자동화 Test 한계점

  • 매우 복잡한 시나리오인 경우 테스트 코드 자체를 작성하는 것이 실제 개발하는 것보다 더 어렵고 시간이 오래 걸릴수가 있습니다.
  • 다양한 예외처리 (예를 들어 서버가 실패하는 시나리오) 를 검증하기 어렵습니다.

E2E 자동화 Test를 적용해야 하는 타겟

앱의 모든 기능에 대해서 E2E 자동화 테스트를 적용하는 것은 현실적으로 어려울 수 있습니다. 따라서, E2E 자동화 테스트를 적용할 때에는 다음과 같은 기준을 고려하여 적용하는 것이 좋습니다.

  • 중대한 핵심 기능: E2E 테스트는 시스템의 가장 중요하고 핵심적인 기능들, 특히 실패 시 심각한 결과를 초래할 수 있는 기능들에 초점을 맞춰야 합니다. 이러한 기능들은 시스템의 안정성과 사용자 경험에 직접적인 영향을 미치기 때문에, 신중하게 테스트되어야 합니다.
  • 개발 완료 및 UI 변경 가능성이 낮은 기능: 개발이 완전히 마무리된 기능들, 특히 추후에 UI 시나리오나 기능 자체에 대한 변경 가능성이 낮은 기능들은 E2E 테스트의 이상적인 대상입니다. 이러한 기능들은 테스트 스크립트를 자주 수정할 필요가 없어, 테스트의 유지 관리 부담을 줄일 수 있습니다.

2. Flutter Sample app 에 E2E Test 적용하기

먼저 아래 간단한 예제를 통해서 E2E Test 를 적용해보겠습니다.

Sample App 소개 및 코드

아래 플로팅 버튼을 누르면 카운터가 증가하는 간단한 앱을 예제로 사용하겠습니다.

image
Dart Code
import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Counter App',
      home: MyHomePage(title: 'Counter App Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        // Provide a Key to this button. This allows finding this
        // specific button inside the test suite, and tapping it.
        key: const Key('increment'),
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

integrate test 패키지 추가

Integrate test 를 사용하기 위해서는 integration_test 패키지를 추가해야 합니다.

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

Sample App E2E Test 코드

해당 앱에 대한 E2E Test 코드는 아래와 같습니다.

Dart Code
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:introduction/main.dart';

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized(); // 테스트 코드의 최상단에 해당 코드를 호출합니다.

  group('end-to-end test', () {
    testWidgets('tap on the floating action button, verify counter',
        (tester) async {
      // 최상위 위젯을 로드 합니다.
      await tester.pumpWidget(const MyApp());

      // 첫 화면에서 텍스트 0을 찾습니다.
      expect(find.text('0'), findsOneWidget);

      // 플로팅 액션 버튼을 찾습니다. 여기서 Key 값은 위젯의 Key 값과 동일해야 합니다.
      final fab = find.byKey(const Key('increment'));

      // 플로팅 액션 버튼을 탭합니다.
      await tester.tap(fab);

      // 화면을 갱신합니다.
      await tester.pumpAndSettle();

      // 텍스트 1을 찾습니다.
      expect(find.text('1'), findsOneWidget);
    });
  });
}

E2E Test 실행

아래 명령어를 수행하면 현재 컴퓨터에 연결된 디바이스나 에뮬레이터에서 E2E Test 가 실행됩니다.

flutter test integration_test/app_test.dart

Flutter integration_test 패키지 설명

flutter integration test 를 이해하기 위해서는 아래 Tester, Finder, Assertion 에 대한 이해가 필요합니다.

Tester

Flutter에서의 테스트 과정을 이해하는 데 있어, 실제 사용자나 테스터가 단말기를 조작하는 행위를 모방하는 것이 핵심입니다. 이를 위해 Flutter 테스트 프레임워크는 사용자의 상호작용을 재현할 수 있는 Teseter 를 제공합니다. 특히, 화면 탭(tap), 화면 스크롤(scroll), 텍스트 입력(TextField) 과 같은 기본적인 사용자 상호작용을 시뮬레이션할 수 있는 API들이 포함되어 있습니다.

또한, Flutter 테스트에서는 위젯의 상태 변화와 애니메이션 처리를 위한 중요한 API들이 제공됩니다:

  • pumpWidget() 은 테스트 환경에서 특정 위젯을 초기화하고 렌더링하는 데 사용됩니다. 이는 앱이 시작될 때 runApp()에 의해 실행되는 주 위젯을 설정하는 과정과 유사합니다. 이 메서드를 통해, 테스트하고자 하는 위젯을 테스트 트리의 루트로 설정할 수 있습니다.
  • pump() 는 위젯 트리에 단일 프레임을 트리거하는 데 사용되며, 선택적으로 특정 시간 동안 대기할 수 있습니다. 예를 들어, pump(Duration(seconds: 1))을 호출하면, 위젯 트리가 업데이트되고 1초 동안 대기한 후 다음 코드 라인이 실행됩니다. 이는 애니메이션 진행이나 UI 변화가 한 단계씩 진행될 때 유용합니다.
  • pumpAndSettle() : 애니메이션과 같은 비동기 작업들이 완료될 때까지 여러 번 프레임을 트리거합니다. 이는 애니메이션이나 다른 비동기 이벤트들이 완료되고 위젯 트리가 안정화될 때까지 반복적으로 프레임을 생성합니다. 기본적으로, 이 메서드는 100ms 간격으로 프레임을 생성하며, 최대 300번 시도한 후에 멈춥니다. 애니메이션이나 비동기 이벤트들이 모두 완료될 때까지 기다리는 상황에서 매우 유용합니다.

Finder

Flutter 테스트에서 find는 위젯을 찾는 데 사용되는 다양한 방법들을 제공합니다. find를 통해 테스트하려는 위젯을 정확하게 식별하고 검증할 수 있습니다. 다음은 몇 가지 일반적인 위젯 찾기 방법들입니다:

  1. 텍스트로 찾기: find.text를 사용하여 특정 텍스트를 포함하는 Text 위젯을 찾습니다. 예를 들어, find.text('Hello')는 화면에 ‘Hello’라는 텍스트를 포함하는 모든 위젯을 찾습니다.
  2. 키로 찾기: find.byKey를 사용하여 특정 Key를 가진 위젯을 찾습니다. 예를 들어, find.byKey(Key('uniqueKey'))는 ‘uniqueKey’라는 키를 가진 위젯을 찾습니다.
  3. 타입으로 찾기: find.byType를 사용하여 특정 타입의 위젯을 찾습니다. 예를 들어, find.byType(IconButton)는 모든 IconButton 위젯을 찾습니다.
  4. 아이콘으로 찾기: find.byIcon을 사용하여 특정 아이콘을 가진 Icon 위젯을 찾습니다. 예를 들어, find.byIcon(Icons.add)는 ‘add’ 아이콘을 가진 위젯을 찾습니다.
  5. 위젯 특성으로 찾기: find.byWidgetPredicate를 사용하여 특정 조건을 만족하는 위젯을 찾습니다. 예를 들어, 특정 위젯이 Container이고 특정 색상을 가지는지 확인할 수 있습니다.
  6. 애니메이션으로 찾기: find.byAnimation을 사용하여 특정 Animation을 가진 위젯을 찾습니다. 예를 들어, 특정 애니메이션 컨트롤러를 사용하는 위젯을 찾을 수 있습니다.
  7. 조건을 충족하는 첫 번째/마지막 위젯 찾기: find.firstfind.last를 사용하여 조건을 충족하는 첫 번째나 마지막 위젯을 찾습니다.

Assertion

Flutter 테스트 프레임워크에서는 다양한 종류의 assertion을 제공합니다. 이들은 테스트 중에 특정 조건이 충족되는지 확인하는 데 사용됩니다. 여기 몇 가지 자주 사용되는 assertion들을 소개합니다:

  1. findsOneWidget: expect 함수와 함께 사용될 때, 이 assertion은 정확히 하나의 위젯이 찾아지기를 기대합니다. 예를 들어, expect(find.text('Hello'), findsOneWidget);은 ‘Hello’라는 텍스트를 포함하는 위젯이 정확히 하나만 존재해야 함을 검증합니다.
  2. findsNothing: 이 assertion은 주어진 조건을 만족하는 위젯이 하나도 없어야 함을 검증합니다. 예를 들어, expect(find.text('Goodbye'), findsNothing);은 ‘Goodbye’라는 텍스트를 포함하는 위젯이 없어야 함을 의미합니다.
  3. findsWidgets: 이 assertion은 하나 이상의 위젯이 찾아지기를 기대합니다. 예를 들어, expect(find.byType(TextButton), findsWidgets);는 하나 이상의 TextButton 위젯이 존재해야 함을 검증합니다.
  4. findsNWidgets: 특정 수의 위젯이 찾아지기를 기대합니다. 예를 들어, expect(find.byIcon(Icons.add), findsNWidgets(3));는 ‘add’ 아이콘을 가진 위젯이 정확히 세 개 존재해야 함을 의미합니다.

기본 Integrated test package의 한계

  • Native 요소와의 상호 작용 한계: 예를 들어, 퍼미션 팝업: 일부 네이티브 요소, 특히 시스템 권한 요청과 같은 팝업은 E2E 테스트 도구로 직접 상호 작용하는 것이 불가능합니다.
  • 서버 응답 대기 시간 추정 필요성: 서버와의 통신 과정에서 발생하는 로딩 시간을 고려하여, 테스트에서 얼마나 오랫동안 응답을 기다려야 할지를 예측하고 설정해야 합니다. 이는 테스트의 신뢰성과 실행 시간에 직접적인 영향을 미칩니다.
  • pumppumpAndSettle 사용에 대한 혼란: 특정 액션 후에 위젯 업데이트를 기다리기 위해 pump 또는 pumpAndSettle을 호출해야 하는데, 어느 것을 사용해야 할지 결정하는 것이 혼란스러울 수 있습니다. 이 둘은 위젯 상태의 변화를 처리하는 방식에서 차이가 있으며, 적절한 선택이 중요합니다.
  • 위젯 상호 작용의 복잡성: 위젯과 상호 작용하기 위해 finder를 사용하여 위젯을 찾고, assertion을 통해 실제로 존재하는지 확인한 후, tap과 같은 액션 API를 호출하고, 이후에 pump 또는 pumpAndSettle을 호출하는 과정은 번거롭고 가독성이 떨어질 수 있습니다. 이러한 절차는 테스트 코드의 복잡성을 증가시킵니다.

Patrol 라이브러리 도입 및 장점

iShot_2024-02-04_09 33 17

Patrol 라이브러리는 기본 integrated test package의 문제점을 해결하기 위해 특별히 설계된 혁신적인 도구입니다. 이 라이브러리의 주요 목적은 개발자들이 앱의 전체적인 기능과 성능을 보다 효율적으로 검증할 수 있도록 지원하는 것입니다. Patrol을 사용함으로써, 테스트 작성과 관리의 복잡성이 대폭 감소합니다.

Patrol 라이브러리의 주요 장점

  • 네이티브 요소와의 상호 작용 가능: Patrol을 사용하면 네이티브 요소와의 상호 작용이 가능해져, 이전에는 테스트하기 어려웠던 시나리오들을 다룰 수 있게 됩니다.
  • 서버 응답 대기 시간 설정의 용이성: Patrol에서는 서버 응답을 기다리는 시간을 사용자가 직접 설정할 수 있습니다. 설정된 시간은 최대 대기 시간으로 작용하며, 응답이 빠르게 도착하면 즉시 다음 테스트 단계로 넘어갑니다.
  • pumppumpAndSettle의 간소화: Patrol은 내부적으로 pumppumpAndSettle를 적절히 호출합니다. 따라서 개발자는 이러한 함수를 직접 호출할 필요가 거의 없어지며, 테스트 코드 작성이 더 간편해집니다.
  • 코드 간소화 및 가독성 향상: Patrol은 특정 위젯과의 상호 작용을 위한 복잡한 코드를 단순화합니다. 이를 통해 가독성이 높고 직관적인 코드 작성이 가능해지며, 불필요한 보일러플레이트 코드가 대폭 줄어듭니다.

Patrol 설치

Patrol 라이브러리를 사용하기 위해서는 pubspec.yaml 파일에 추가하는 것 뿐만 아니라 patrol cli 도 설치해야 합니다. 설치 방법 및 셋팅은 Patrol 공식 웹사이트에서 확인할 수 있습니다.

Patrol Finder

Patrol 라이브러리에서 가장 장점중 하나가 기존 Package 보다 향상된 Widget finder 를 통해서 직관적으로 짧은 코드로 구현이 가능하다는 점입니다.

예시를 하나 들어서 살펴보겠습니다. 만약 버튼 하나를 찾아서 클릭하는 테스트 코드를 작성한다고 가정해보겠습니다.

final fab = find.byKey(const Key('increment'));
await tester.tap(fab);
await tester.pumpAndSettle();

위 코드를 Patrol에서는 아래와 같이 간단하게 작성이 가능합니다.

await $(#increment).tap();

Patrol 라이브러리에서 $ 기호는 매우 중요한 역할을 합니다. 이 기호는 기존의 Finder, Tester 그리고 Assertion 기능을 통합한 것으로, 위젯을 찾고 상호 작용하는 데 사용됩니다. 이러한 통합은 테스트 코드를 간소화하고 가독성을 높이는 데 크게 기여합니다.

또한, # 기호는 위젯의 key 값을 지정하는 데 사용됩니다. 예를 들어, #incrementkey 값이 increment인 위젯을 찾는 데 사용됩니다. 이는 특정 위젯을 식별하고 테스트 코드에서 참조하기 쉽게 만들어 줍니다.

Patrol 라이브러리의 사용성과 간소화된 코드 구조는 아래 예제를 통해 더욱 명확하게 이해할 수 있습니다.

SignUp 화면에서 Email, Name, Password 입력 후 SignUp 버튼을 클릭하는 테스트 코드를 작성한다고 가정해보겠습니다.

먼저 기본 Integrated test 패키지를 사용한 코드입니다.

testWidgets('signs up', (WidgetTester tester) async {
  await tester.pumpWidget(AwesomeApp());
  await tester.pumpAndSettle();

  await tester.enterText(
    find.byKey(Key('emailTextField')),
    'charlie@root.me',
  );
  await tester.pumpAndSettle();

  await tester.enterText(
    find.byKey(Key('nameTextField')),
    'Charlie',
  );
  await tester.pumpAndSettle();

  await tester.enterText(
    find.byKey(Key('passwordTextField')),
    'ny4ncat',
  );
  await tester.pumpAndSettle();

  await tester.tap(find.byKey(Key('termsCheckbox')));
  await tester.pumpAndSettle();

  await tester.tap(find.byKey(Key('signUpButton')));
  await tester.pumpAndSettle();

  expect(find.text('Welcome, Charlie!'), findsOneWidget);
});

그 다음 Patrol 라이브러리를 사용한 코드입니다.

patrolWidgetTest('signs up', (PatrolTester $) async {
  await $.pumpWidgetAndSettle(AwesomeApp());

  await $(#emailTextField).enterText('charlie@root.me');
  await $(#nameTextField).enterText('Charlie');
  await $(#passwordTextField).enterText('ny4ncat');
  await $(#termsCheckbox).tap();
  await $(#signUpButton).tap();

  await $('Welcome, Charlie!').waitUntilVisible();
});

단순 코드를 비교해도 코드량이 줄어들고 코드 자체가 매우 직관적으로 변화하는 것을 확인할 수 있습니다.

Native 요소와의 상호 작용

Patrol에서는 네이티브 요소와의 상호 작용이 가능해져, 이전에는 테스트하기 어려웠던 시나리오들을 다룰 수 있게 됩니다. 예를 들어, 퍼미션 팝업과 같은 네이티브 요소들을 테스트하는 것이 가능해집니다.

void main() {
  patrolTest('demo', (PatrolIntegrationTester $) async {
    await $.pumpWidgetAndSettle(AwesomeApp());
    // prepare network conditions
    await $.native.enableCellular();
    await $.native.disableWifi();

    // toggle system theme
    await $.native.enableDarkMode();

    // handle native location permission request dialog
    await $.native.selectFineLocation();
    await $.native.grantPermissionWhenInUse();

    // tap on the first notification
    await $.native.openNotifications();
    await $.native.tapOnNotificationByIndex(0);
  });
}

위 OS 레벨의 상호작용 뿐만 아니라 Google Login 같이 외부 서비스와의 상호작용도 가능합니다. 해당 내용은 다음 포스트에서 자세히 다루도록 하겠습니다.

실제 Product 에 Patrol 라이브러리 적용해보기

먼저 제가 속해있는 팀에서 개발 중인 앱, B^ Discover에 대해 소개드리겠습니다. 이 앱은 AI 기술을 활용하여 사용자가 입력한 텍스트(Prompt)를 바탕으로 다양한 멀티미디어 콘텐츠를 생성해 주는 서비스를 제공합니다. 사용자의 아이디어를 시각적 형태로 변환해주어, 창의적인 작업을 더 쉽고 효율적으로 만들어줍니다.

관심 있으신 분들은 다음 링크를 통해 다운로드 받으실 수 있습니다

그리고 저는 해당 앱 프로젝에서 Patrol 라이브러리를 적용해 보았으며, 이를 통해 앱의 성능과 사용자 경험을 한층 더 향상시키고자 하였습니다.

테스트 시나리오 구성

사용자가 앱을 실행하고, 특정 텍스트를 입력하고, 이를 바탕으로 AI가 이미지를 생성하는 과정을 테스트하고자 합니다.

구체적인 시나리오와 테스트 코드는 아래와 같습니다.

최종 실행 결과

위 시나리오와 같이 테스트 코드를 작성하고 Patrol 라이브러리를 적용한 결과, 아래와 같이 테스트가 성공적으로 실행되었습니다. 아래는 실제로 Patrol 라이브러리를 적용해서 실행되는 것을 캡처한 화면입니다.

마치며

Fluttter E2E Test 를 작성할 때 기본 패키지보다는 Patrol 라이브러리를 사용하는 것이 훨씬 효율적이고 가독성이 좋다는 것을 알 수 있었습니다. 위 시나리오에 대해서 기본 패키지로 구현하게 된다면 훨씬 더 많은 코드(250줄 정도)를 작성해야 하고 그리고 또한 pumppumpAndSettle 을 잘못 사용하게 되면 테스트가 실패할 수도 있습니다. 그래서 디버깅을 하고 오류를 찾는 과정이 매우 고통스웠습니다.

하지만 Patrol 라이브러리를 사용하게 되면 훨씬 더 간단하고 직관적인 코드(100줄 미만)를 작성할 수 있었습니다. 그리고 또한 네이티브 요소와의 상호작용이 가능해져서 테스트하기 어려웠던 시나리오들을 다룰 수 있게 됩니다.

요약을 하자면

  1. Patrol 라이브러리를 사용하면 훨씬 더 간단하고 직관적인 코드를 작성할 수 있습니다.
  2. 네이티브 요소와의 상호작용이 가능해져서 테스트하기 어려웠던 시나리오들을 다룰 수 있게 됩니다.

그리고 다음 포스팅에서는 아래와 같은 내용을 다루도록 하겠습니다.

  1. Patrol를 이용하여 Google Login 같은 외부 서비스와의 상호작용 및 테스트
  2. E2E Test 과정을 Github actions 의 CI에 적용하는 방법

Reference

댓글남기기