image

Overview

이번 포스팅에서는 E2E Test를 CI에 적용하는 방법에 대해 알아보겠습니다. 여기서는 E2E Test를 지원하기 위해 기본 integration_test 패키지를 사용하지 않고 patrol 패키지를 사용하고 있습니다.

patrol 패키지에 대해서 알아보시려면 E2E Test (1) - Patrol 패키지 사용하기 포스팅을 참고해주세요.

환경

CI

Flutter CI 를 지원하는 여러 서비스 중에서 Github Actions 를 이용해서 설정을 진행하겠습니다.

Test 환경

CI 환경에서 테스트를 진행할 때 고려할 수 있는 몇 가지 옵션이 있습니다:

  • 시뮬레이터
  • Firebase Test Lab
  • AWS Device Farm

이 중 시뮬레이터 사용은 추천하지 않습니다. 실제로 시뮬레이터를 이용한 테스트는 속도가 느리고, 예기치 못한 오류로 인해 테스트가 제대로 수행되지 않는 경우가 많기 때문입니다.

본 포스트에서는 Firebase Test Lab을 사용하여 테스트하는 방법에 초점을 맞추겠습니다.

Firebase Test Lab

image

Firebase Test Lab은 Google Cloud의 일부로서, 개발자가 다양한 기기와 구성에서 자신의 Android 및 iOS 애플리케이션을 테스트할 수 있게 해주는 클라우드 기반의 앱 테스팅 서비스입니다. 이 서비스를 사용하면 개발자는 실제 기기를 소유하고 있지 않아도, 여러 기기와 운영 체제 버전에서 앱의 성능과 안정성을 테스트하고 문제를 식별할 수 있습니다.

Firebase Test Lab의 주요 기능은 다음과 같습니다:

  • 다양한 기기와 환경에서의 테스트: Firebase Test Lab은 실제 기기 뿐만 아니라 에뮬레이터와 시뮬레이터를 통해 여러 안드로이드와 iOS 기기 및 버전에서 앱을 테스트할 수 있는 환경을 제공합니다.
  • 테스트 결과 및 로그: 테스트 실행 후, Firebase Test Lab은 자세한 결과, 스크린샷, 비디오, 로그를 제공하여 개발자가 문제를 쉽게 식별하고 수정할 수 있게 해줍니다.
  • 통합과 접근성: Firebase Test Lab은 Firebase 및 Google Cloud 플랫폼과 밀접하게 통합되어 있어, CI/CD 파이프라인과 같은 다른 개발 도구와 쉽게 연동할 수 있습니다.

Github Actions Workflow 구성

다음 단계로는 Github Actions Workflow를 구성하는 방법에 대해 알아보겠습니다.

먼저 전체적인 flow는 아래와 같습니다.

image

준비

Github Actions 에서 Firebase Test Lab 과 통신하기 위해서는 GCP를 통해서 생성된 service-account.json 파일이 필요하고 해당 파일은 Github Actions의 Secret에 미리 저장해두어야 합니다.

service-account.json 파일을 생성하는 방법은 아래 링크를 참고해주세요.

그리고 Firebase Test Lab 의 결과값을 저장하기 위해서는 bucket 이 필요합니다. 여기서 bucket 은 Firebase Console 에서 Storage 에서 생성할 수 있습니다. 기존의 bucket 을 사용하거나 새로 생성하여 사용하시면 됩니다. 저는 새로운 bucket 을 생성하여 사용하는 것을 추천드립니다.

image

Android

먼저 Android 같은 경우 아래와 같이 Workflow를 구성하였습니다.

image

해당 yaml 파일은 아래와 같습니다.

name: Run Flutter E2E (android) test

jobs:
  e2e:
    runs-on: ubuntu-latest
    outputs:
      SLACK_MESSAGE_TITLE: Flutter E2E Test on ${{ matrix.os }} ${{ matrix.os_version }}
      TESTS_EXIT_CODE: ${{ steps.tests_step.outputs.TESTS_EXIT_CODE }}
      URL_TO_DETAILS: ${{ steps.tests_step.outputs.URL_TO_DETAILS }}
    
    strategy:  # 테스트를 하고자 하는 디바이스 설정
      matrix:
        os: ['Android API']
        include:
          - device_model: 'oriole'
            os_version: '33'

    steps:
      # 1. 코드 체크아웃
      - uses: actions/checkout@v3  

      # 2.1 Flutter 설치
      - uses: subosito/flutter-action@v2
        with:
          channel: 'stable'

      # 2.2. 의존성 설치
      - name: Install dependencies 
        run: flutter pub get

      # 2.3. Gradle wrapper 생성
      - name: Generate Gradle wrapper  
        run: flutter build apk --config-only

      # 3.1. Patrol CLI 설치
      - name: Set up Patrol CLI  
        run: dart pub global activate patrol_cli

      # 3.2. Patrol 빌드
      - name: patrol build android  
        run: patrol build android

      # 4.1. service-account.json 인증
      - name: Authenticate to Google Cloud  
        uses: google-github-actions/auth@v1
        with:
          credentials_json: '${{ secrets.GCP_STAGE_SERVICE_ACCOUNT_JSON }}'

      # 4.2. gcloud 설정
      - name: Set up Cloud SDK   
        uses: google-github-actions/setup-gcloud@v1

      # 5. Firebase Test Lab 에 APK 업로드 및 테스트 실행
      - name: Upload APKs to Firebase Test Lab and wait for tests to finish  
        id: tests_step
        env:
          ANDROID_DEVICE_MODEL: ${{ matrix.device_model }}
          ANDROID_DEVICE_VERSION: ${{ matrix.os_version }}
        run: |
          set +e
          set -euo pipefail
         
          # Firebase Test Lab 실행 및 결과 저장
          output=$(set -euo pipefail && \
          gcloud firebase test android run \
            --type instrumentation \
            --app build/app/outputs/apk/debug/app-debug.apk \
            --test build/app/outputs/apk/androidTest/debug/ap-debug-androidTest.apk \
            --device model="$ANDROID_DEVICE_MODEL",version="$ANDROID_DEVICE_VERSION",locale=en,orientation=portrait \
            --timeout 10m \
            --results-bucket="<버킷 이름 입력>" \
            --use-orchestrator \
            --environment-variables clearPackageData=true 2>&1)

          TESTS_EXIT_CODE=$?
          set -e

          # Extract the last link using grep, tail, and sed, and write it to Github Summary
          link="$(echo "$output" | grep -o 'https://[^ ]*' | tail -1 | sed 's/\[//;s/\]//')"
          echo "[Test details on Firebase Test Lab]($link) (Firebase members only)" >> "$GITHUB_STEP_SUMMARY"

          echo "URL_TO_DETAILS=$link" >> "$GITHUB_OUTPUT"
          echo "TESTS_EXIT_CODE=$TESTS_EXIT_CODE" >> "$GITHUB_OUTPUT"
          exit $TESTS_EXIT_CODE
  
  # 6. Slack 메시지 전송
  call_send_slack_message:  
    name: Notify on Slack
    uses: ./.github/workflows/send_slack_message.yml
    needs: e2e
    if: always()
    with:
      TESTS_EXIT_CODE: ${{ needs.e2e.outputs.TESTS_EXIT_CODE }}
      SLACK_MESSAGE_TITLE: ${{ needs.e2e.outputs.SLACK_MESSAGE_TITLE }}
      URL_TO_DETAILS: ${{ needs.e2e.outputs.URL_TO_DETAILS }}
    secrets: inherit

위 workflow 에서 slack 메시지 전송 부분은 별도의 파일로 분리하여 사용하였습니다. slack 메시지 전송은 필수가 아니기 때문에 slack 메시지 전송 상세 워크플로우는 생략하겠습니다.

iOS

iOS 같은 경우 아래와 같이 Workflow를 구성하였습니다.

image

iOS 의 경우 Code Signing 이 필요하기 때문에 Fastlane 을 사용하여 Code Signing 을 진행하였습니다.

name: Run Flutter E2E (ios) test

jobs:
  e2e:
    runs-on: macos-latest
    outputs:
      SLACK_MESSAGE_TITLE: Flutter E2E Test on ${{ matrix.os }} ${{ matrix.os_version }}
      TESTS_EXIT_CODE: ${{ steps.tests_step.outputs.TESTS_EXIT_CODE }}
      URL_TO_DETAILS: ${{ steps.tests_step.outputs.URL_TO_DETAILS }}
    strategy:
    # 테스트를 하고자 하는 디바이스 설정
      matrix:  
        device_model: ['iphone14pro']
        os_version: ['16.6']
        os: [iOS]
    steps:
      # 1. 코드 체크아웃
      - uses: actions/checkout@v3 
    
      # 2.1 Flutter 설치
      - uses: subosito/flutter-action@v2 
        with:
          channel: 'stable'

      # 2.2. 의존성 설치
      - name: Install dependencies 
        run: flutter pub get
    
      # 3.1 Ruby 설치 (Fastlane 사용을 위해)
      - name: Install Ruby  
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: 2.7.2
          bundler-cache: true
        
      # 3.2 bundle 설치 (Fastlane 사용을 위해)
      - name: Install bundle  
        run: |
          cd ios
          bundle config path vendor/bundle
          bundle install --jobs 4 --retry 3

      # 4. Fastlane 사용하여 Certificate, Provisioning Profile 설치
      # 5. Fastlane 사용하여 Xcode Code Signing 변경
      - name: Sign iOS app  
        uses: maierj/fastlane-action@v3.0.0
        with:
          lane: code_sign_developemnt
          subdirectory: ios
      
      # 6.1 Patrol CLI 설치
      - name: Set up Patrol CLI   
        run: dart pub global activate patrol_cli

      # 6.2 Patrol 빌드
      - name: patrol build ios
        run: patrol build ios

      # 7.1 service-account.json 인증
      - name: Authenticate to Google Cloud  
        uses: google-github-actions/auth@v1
        with:
          credentials_json: '${{ secrets.GCP_STAGE_SERVICE_ACCOUNT_JSON }}'

      # 7.2 gcloud 설정
      - name: Set up Cloud SDK  
        uses: google-github-actions/setup-gcloud@v1

      # 8. Firebase Test Lab 에 APK 업로드 및 테스트 실행
      - name: Upload iOS App to Firebase Test Lab and wait for tests to finish  
        id: tests_step
        env:
          IOS_DEVICE_MODEL: ${{ matrix.device_model }}
          IOS_DEVICE_VERSION: ${{ matrix.os_version }}
        run: |
            set +e

            # iOS 테스트 준비 및 Firebase Test Lab 실행
            output=$(cd build/ios_integ/Build/Products && \
                    rm -f ios_tests.zip && \
                    zip -r ios_tests.zip Release-iphoneos/*.app *.xctestrun && \
                    cd - && \
                    gcloud firebase test ios run \
                      --type xctest \
                      --test "build/ios_integ/Build/Products/ios_tests.zip" \
                      --device model="$IOS_DEVICE_MODEL",version="$IOS_DEVICE_VERSION",locale=en_US,orientation=portrait \
                      --timeout 10m \
                      --results-bucket=""<버킷 이름 입력>" 2>&1)

            TESTS_EXIT_CODE=$?
            set -e

            # 출력 및 링크 추출
            echo "$output"
            link="$(echo "$output" | grep -o 'https://[^ ]*' | tail -1 | sed 's/\[//;s/\]//')"
            echo "[Test details on Firebase Test Lab]($link) (Firebase members only)" >> "$GITHUB_STEP_SUMMARY"
            echo "URL_TO_DETAILS=$link" >> "$GITHUB_OUTPUT"
            echo "TESTS_EXIT_CODE=$TESTS_EXIT_CODE" >> "$GITHUB_OUTPUT"
            exit $TESTS_EXIT_CODE
  
  # 9. Slack 메시지 전송
  call_send_slack_message:  
    name: Notify on Slack
    uses: ./.github/workflows/send_slack_message.yml
    needs: e2e
    if: always()
    with:
      TESTS_EXIT_CODE: ${{ needs.e2e.outputs.TESTS_EXIT_CODE }}
      SLACK_MESSAGE_TITLE: ${{ needs.e2e.outputs.SLACK_MESSAGE_TITLE }}
      URL_TO_DETAILS: ${{ needs.e2e.outputs.URL_TO_DETAILS }}
    secrets: inherit

Code Signing 을 위한 Fastlane 은 내부적으로 Fastlane match 를 사용하였습니다. Fastlane match 를 사용하면 Code Signing 관리를 쉽게 할 수 있습니다. Fastlane match 은 설정이 조금 복잡하고 어렵기 때문에 해당 포스팅에서는 Fastlane match 설정에 대해서는 다루지 않겠습니다.

Fastlane match 를 통해 Code Signing 을 진행할 때 development 타입 인증서와 provisioning profile 를 사용하는 것을 권장드립니다. 그 이유는 E2E Test binary 를 실행하기 위해서는 provisioning profile의 entitlements에 get-task-allow가 포함되어 있어야 하며 development 타입 인증서와 provisioning profile 에는 get-task-allow가 포함되어 있기 때문입니다.

실행 결과

위와 같이 Workflow를 구성하고 실행하면 아래와 같이 Firebase Test Lab 에서 테스트가 진행되고 결과가 저장됩니다.

iShot_2024-02-13_11 12 36

테스트 유형에서 XCTest 는 iOS 테스트를 의미하고 도구 작동 은 Android 테스트를 의미합니다.

상세 결과를 확인하고 싶다면 항목 하나를 클릭하면 아래와 같이 상세 결과(로그, 실행 동영상)를 확인할 수 있습니다.

iShot_2024-02-13_11 15 19 iShot_2024-02-13_11 15 40

마무리

Patrol 패키지를 사용하여 E2E Test를 CI에 적용하는 방법에 대해서 알아보았습니다. 위에 정리된 워크플로우에 대해서는 각각의 상황에 맞게 수정하여 사용하시면 됩니다.

처음 이 방법을 도입할 때는 여러 오류와 문제에 직면할 수 있습니다. 하지만, 이러한 문제를 해결하고 나면, CI/CD 파이프라인을 구축하는 과정이 매우 즐거운 경험이 될 것입니다. 혹시나 문제가 발생하거나 궁금한 점이 있으시면 언제든지 댓글로 남겨주세요.

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

  • Patrol를 이용하여 Google Login 같은 외부 서비스와의 상호작용 및 테스트

감사합니다.

Reference

댓글남기기