Flutter 프로젝트에 CI 도입하기

koeyhoyh·2024년 6월 23일
1

App_Flutter

목록 보기
20/22

Flutter의 Integration test를 CI에 어떻게 도입했는지, 그리고 어떤 문제점을 겪었는지 공유드리려 글을 작성하게 되었습니다.

목차는 다음과 같습니다.

1. Flutter Integration Test?

2. Github Actions yaml 파일 설명


1. Flutter Integration Test?

먼저, Flutter의 Integration test가 무엇인지 간단히 비교하며 설명드리겠습니다.
Flutter의 Integration test는 실제 기기나 에뮬레이터에서 실행됩니다. 그렇기 때문에 테스트에 (훨씬)더 많은 시간이 걸리지만 실제 환경에서 테스트해볼 수 있다는 장점이 있습니다.

테스트 종류Unit_test, Widget_testIntegration_test
목적단일 위젯 테스트, UI와 인터랙션 검증전체 애플리케이션 또는 주요 플로우 테스트
범위개별 위젯 또는 특정 화면의 일부 테스트애플리케이션의 전반적인 기능 테스트
의존성외부 시스템과 의존성 없음네트워크, 데이터베이스 등 외부 시스템과의 통합 테스트
실행 속도비교적 빠름비교적 느림
테스트 환경가상 환경실제 디바이스 또는 에뮬레이터
사용 패키지flutter_testintegration_test

배경을 설명드리자면,
lint와 unit_test, widget_test는 pre-commit을 도입해 커밋마다 검사했지만 Integration test는 테스트에 시간이 너무 오래 걸려 따로 pre-commit에 넣는 것이 아니라, CI에 넣고 pull&request를 할 때 확인하자고 동료분과 합의했습니다.

안드로이드와 iOS 에뮬레이터를 실행해 flutter test integration_test 만 돌리면 될 것 같다고 생각했지만 생각보다 더 어려웠고, 많은 시간이 걸렸으며, 개인에 할당된 Actions 시간도 다 써버려 계정을 돌려가며 테스트를 진행했습니다.

(후에 찾아보니 act 라는, github actions와 비슷한 환경에서 테스트를 실행시켜 주는 툴이 있다는 것을 발견했습니다. 정확히 100% 같은 동작을 보장하지는 않는다고 하니 참고 바랍니다.)

많은 시간이 걸린 이유는, CI에 시간이 오래 걸렸기 때문입니다. 에뮬레이터에 대해 캐싱을 해도 한 워크플로우에 10분이 넘게 걸렸기 때문에 테스트하고 수정하는데 시간이 오래 걸렸습니다.

(빌드 시간)
(전체 테스트 시간)

이 글을 읽는 분이시라면, 동작하는 아래 코드를 바탕으로 작성하셔서 조금이라도 시간을 절약하실 수 있으면 좋을 것 같습니다.


2. Github Actions yaml 파일 설명

전체 소스코드는 아래와 같습니다.

name: Integration Test CI

on:
  pull_request:
    branches: [main, pre-production, production]
  workflow_dispatch:
  
concurrency:
  group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
  cancel-in-progress: true

jobs:
  ios_integration_test:
    runs-on: macos-latest

    steps:
    - name: Checkout mock-auth-server repository
      uses: actions/checkout@v4
      with:
        repository: userName/mock-auth-server
        path: mock-auth-server
        token: ${{ secrets.CHECKOUT_TOKEN }}

    - name: Install Node.js
      uses: actions/setup-node@v3
      with:
        node-version: '20'

    - name: Install mock-auth-server dependencies
      run: |
        cd mock-auth-server
        npm install
        HOST=0.0.0.0 PORT=3000 node server.js &
        sleep 10

    - name: Checkout Flutter repository
      uses: actions/checkout@v4

    - name: Set up Flutter
      uses: subosito/flutter-action@v2
      with:
        channel: 'stable'
        flutter-version: 3.22.1
    - run: flutter --version

    - name: Install CocoaPods
      run: sudo gem install cocoapods

    - name: Install Flutter dependencies
      run: | 
        flutter pub get
        cd ios
        pod install
        cd ..

    - name: Get the host machine's IP address
      id: host-ip
      run: |
        echo "HOST_IP=$(ifconfig | grep 'inet ' | grep -v '127.0.0.1' | awk '{print $2}' | head -n 1)" >> $GITHUB_ENV
        echo $HOST_IP

    - name: Create .env file
      run: echo "HOST_IP=$HOST_IP" > .env

    - name: Launch Simulator
      uses: futureware-tech/simulator-action@v3
      with:
        model: 'iPhone 15 Pro Max'

    - name: Run Flutter Integration Tests
      run: |
        DEVICE_ID="9DB1AB7D-C484-4070-AE84-57D256323404" # UDID for iPhone 15 Pro Max
        echo $HOST_IP
        flutter test integration_test --device-id=$DEVICE_ID

  android_integration_test:
    runs-on: ubuntu-latest

    steps:
    - name: Checkout mock-auth-server repository
      uses: actions/checkout@v4
      with:
        repository: userName/mock-auth-server
        path: mock-auth-server
        token: ${{ secrets.CHECKOUT_TOKEN }}

    - name: Install Node.js
      uses: actions/setup-node@v3
      with:
        node-version: '20'

    - name: Install mock-auth-server dependencies
      run: |
        cd mock-auth-server
        npm install
        HOST=0.0.0.0 PORT=3000 node server.js &
        sleep 10

    - name: Checkout Flutter repository
      uses: actions/checkout@v4

    - name: Set up Flutter
      uses: subosito/flutter-action@v2
      with:
        channel: 'stable'
        flutter-version: 3.22.1
    - run: flutter --version

    - name: Install Flutter dependencies
      run: flutter pub get

    - name: Create key.properties file
      run: echo "${{ secrets.KEY_PROPERTIES }}" > android/key.properties

    - name: Create local.properties file
      run: echo "${{ secrets.LOCAL_PROPERTIES }}" > android/local.properties

    - 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: Get the host machine's IP address
      id: server-ip
      run: |
        echo "SERVER_IP=$(ifconfig | grep 'inet ' | grep -v '127.0.0.1' | awk '{print $2}' | head -n 1)" >> $GITHUB_ENV
        echo $SERVER_IP

    - name: Create .env file
      run: echo "HOST_IP=$SERVER_IP" > .env

    - name: run tests
      uses: reactivecircus/android-emulator-runner@v2
      with:
        api-level: 34
        target: google_apis_playstore
        arch: x86_64
        profile: Nexus 6
        script: |
          flutter test integration_test

위의 yaml 파일을 공통, Android, iOS 부분으로 나누어 설명드리겠습니다.


공통

name: Integration Test CI

on:
  pull_request:
    branches: [main, pre-production, production]
  workflow_dispatch:

concurrency:
  group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
  cancel-in-progress: true

name: Integration Test CI : Github Actions의 workflow 이름이 됩니다.
![[Pasted image 20240617173032.png]]

on:
  pull_request:
    branches: [main, pre-production, production]
  workflow_dispatch:

on:: 워크플로우가 언제 실행될지 지정합니다.

저는 Gitlab flow 전략을 사용하고 있었기 때문에,
main, pre-production, production branch에 pull_request 시에, 해당 actions을 실행시킨다고 지정했습니다.

workflow_dispatch: 해당 옵션은 사용자가 수동으로 워크플로우를 실행시킬 수 있도록 합니다. Github UI를 통해 사용자가 직접 실행시킬 수 있습니다.

concurrency:
  group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
  cancel-in-progress: true

concurrency:: Github actions의 병렬성 설정을 제어합니다.

  group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}

병렬 실행을 그룹화하는 그룹을 정의합니다.
github.workflow는 현재 실행 중인 워크플로우 이름(여기서는 Integration Test CI)
github.head_ref는 해당 이벤트가 발생한 브랜치,
github.run_id는 현재 실행된 Github actions의 고유 id 입니다.

예를 들어서, 워크플로우 이름이 CI 이고, 브랜치 이름이 feature-1 이라면 group: CI-feature-1 이 됩니다.

cancel-in-progress: true: 새로운 요청이 들어왔을 때, 동일한 그룹에 속한 워크플로우를 취소시킬지 여부입니다. true로 해놓는 것이 비용과 시간을 아낄 수 있어 추천드립니다.

예를 들어서, PR을 날린 후 수정사항이 생겨 한 번 더 커밋했을 때, 워크플로우가 2번 실행되는 것이 아니라 기존에 실행되던 워크플로우는 취소되고, 새로 커밋된 코드를 기준으로 워크플로우를 실행시키게 됩니다.


iOS

ios_integration_test:
    runs-on: macos-latest

    steps:
    - name: Checkout mock-auth-server repository
      uses: actions/checkout@v4
      with:
        repository: userName/mock-auth-server
        path: mock-auth-server
        token: ${{ secrets.CHECKOUT_TOKEN }}

    - name: Install Node.js
      uses: actions/setup-node@v3
      with:
        node-version: '20'

    - name: Install mock-auth-server dependencies
      run: |
        cd mock-auth-server
        npm install
        HOST=0.0.0.0 PORT=3000 node server.js &
        sleep 10

    - name: Checkout Flutter repository
      uses: actions/checkout@v4

    - name: Set up Flutter
      uses: subosito/flutter-action@v2
      with:
        channel: 'stable'
        flutter-version: 3.22.1
    - run: flutter --version

    - name: Install CocoaPods
      run: sudo gem install cocoapods

    - name: Install Flutter dependencies
      run: | 
        flutter pub get
        cd ios
        pod install
        cd ..

    - name: Get the host machine's IP address
      id: host-ip
      run: |
        echo "HOST_IP=$(ifconfig | grep 'inet ' | grep -v '127.0.0.1' | awk '{print $2}' | head -n 1)" >> $GITHUB_ENV
        echo $HOST_IP

    - name: Create .env file
      run: echo "HOST_IP=$HOST_IP" > .env

    - name: Launch Simulator
      uses: futureware-tech/simulator-action@v3
      with:
        model: 'iPhone 15 Pro Max'

    - name: Run Flutter Integration Tests
      run: |
        DEVICE_ID="9DB1AB7D-C484-4070-AE84-57D256323404" # UDID for iPhone 15 Pro Max
        echo $HOST_IP
        flutter test integration_test --device-id=$DEVICE_ID

runs-on: macos-latest Github actions에서 지원하는 서버 중 가장 최근의 MacOS 버전을 사용하겠다는 것을 의미합니다. 그 외에도 ubuntu-..., windows-...가 존재합니다.

    - name: Checkout mock-auth-server repository
      uses: actions/checkout@v4
      with:
        repository: userName/mock-auth-server
        path: mock-auth-server
        token: ${{ secrets.CHECKOUT_TOKEN }}

    - name: Install Node.js
      uses: actions/setup-node@v3
      with:
        node-version: '20'

    - name: Install mock-auth-server dependencies
      run: |
        cd mock-auth-server
        npm install
        HOST=0.0.0.0 PORT=3000 node server.js &
        sleep 10

테스트에서 서버 환경을 100% mocking 할 수 없어 넣은 절차입니다. 특정 레포지토리에서 테스트용 서버를 가져온 후 서버를 실행시켜 주었습니다.

여기서 주의할 점은, 실행시킨 해당 서버는 에뮬레이터에서 localhost:3000 으로 접근이 되지 않는다는 것입니다.

Github actions는 각 워크플로우를 가상환경(격리된 컨테이너 환경)에서 실행시킵니다. 그렇기 때문에 호스트 머신(해당 서비스가 실행되는 실제 서버)의 IP 주소를 받아와 어딘가에 저장하고(주로 환경변수에 저장합니다)테스트를 실행시킬 때 저장한 IP 주소를 통해 서버와 통신해야 합니다.

    - name: Get the host machine's IP address
      id: host-ip
      run: |
        echo "HOST_IP=$(ifconfig | grep 'inet ' | grep -v '127.0.0.1' | awk '{print $2}' | head -n 1)" >> $GITHUB_ENV
        echo $HOST_IP

    - name: Create .env file
      run: echo "HOST_IP=$HOST_IP" > .env

저는 위의 코드와 같이 IP 주소를 받아와 .env 파일에 저장하는 방식으로 구현하였습니다.

    - name: Checkout Flutter repository
      uses: actions/checkout@v4

레포지토리를 불러옵니다.


    - name: Set up Flutter
      uses: subosito/flutter-action@v2
      with:
        channel: 'stable'
        flutter-version: 3.22.1
    - run: flutter --version

Github actions 가상환경 내에 플러터를 설치한 후, 잘 설치되었는지 확인할 수 있도록 flutter --version 명령어를 실행시킵니다.

    - name: Install CocoaPods
      run: sudo gem install cocoapods

iOS 기기로 실행하는 Integration test이므로, CocoaPods을 다운로드 받습니다.

    - name: Install Flutter dependencies
      run: | 
        flutter pub get
        cd ios
        pod install
        cd ..

레포지토리의 ios 폴더로 들어가, pod 파일을 다운로드 받습니다.

    - name: Get the host machine's IP address
      id: host-ip
      run: |
        echo "HOST_IP=$(ifconfig | grep 'inet ' | grep -v '127.0.0.1' | awk '{print $2}' | head -n 1)" >> $GITHUB_ENV
        echo $HOST_IP

    - name: Create .env file
      run: echo "HOST_IP=$HOST_IP" > .env

위에서 설명드렸던, 호스트 머신의 IP 주소를 불러와 .env 파일 안에 저장해주는 과정입니다.

    - name: Launch Simulator
      uses: futureware-tech/simulator-action@v3
      with:
        model: 'iPhone 15 Pro Max'

시뮬레이터를 편하게 불러올 수 있게 도와주는 action을 사용합니다. 목록 링크를 확인하시면 어떤 디바이스를 지원하는지 확인하고 불러올 수 있습니다.

해당 action을 사용하지 않고도 시뮬레이터를 사용할 수 있으므로, 규모가 아주 크고 장기적인 프로젝트인 경우에는 별도로 구성하셔서 의존성을 낮추고 조금 더 안정적으로 구축하실 수 있습니다.

    - name: Run Flutter Integration Tests
      run: |
        DEVICE_ID="9DB1AB7D-C484-4070-AE84-57D256323404" # UDID for iPhone 15 Pro Max
        echo $HOST_IP
        flutter test integration_test --device-id=$DEVICE_ID

flutter test integration_test 명령어를 실행해 테스트를 실행합니다.


Android

중복되는 부분은 제외하고, 계속 설명을 이어가겠습니다.

    runs-on: ubuntu-latest

Android 에뮬레이터를 실행시키는데는 macos가 반드시 필요하지 않으므로, ubuntu-...를 사용합니다.

ubuntu 서버는 1분당 0.008달러, windows는 0.016달러(2배), macos는 0.08달러(10배)입니다.
Github actions는 시간을 배분해주므로 windows는 1분 사용할 때 2분, macos는 1분 사용할 때 10분이 사용됩니다.

    - name: Create key.properties file
      run: echo "${{ secrets.KEY_PROPERTIES }}" > android/key.properties

	- name: Create local.properties file
      run: echo "${{ secrets.LOCAL_PROPERTIES }}" > android/local.properties

관련 앱의 test 버전을 플레이스토어에 올려두었기 때문에, 애플리케이션을 안드로이드용으로 빌드하기 위해서는 key.properties 파일이 필요했습니다.

Repository 내의 Settings - Secrets and variables - Actions -> New repository secret 를 통해 secrets 환경변수에 key, properties와 관련된 파일들을 저장하고 불러와 만들어주었습니다.

    - 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

KVM(Kernel-based Virtual Machine)을 활성화하고 설정하는 데 사용되는 action 입니다.

KVM은 CPU의 하드웨어 가상화 기능을 사용해 가상 머신의 성능을 크게 향상시킵니다. 이를 통해 호스트와 가상 머신 간의 오버헤드가 최소화되고, 거의 네이티브 성능에 가까운 속도를 제공합니다.

    - name: run tests
      uses: reactivecircus/android-emulator-runner@v2
      with:
        api-level: 34
        target: google_apis_playstore
        arch: x86_64
        profile: Nexus 6
        script: |
          flutter test integration_test

여기서도 안드로이드의 에뮬레이터를 편하게 불러올 수 있게 해주는 action을 사용합니다. 해당 action의 자세한 설명은 아래와 같습니다.

  • api-level: Android API 레벨입니다.
  • target: 에뮬레이터의 타겟 이미지입니다.
    - default: 순수한 AOSP(Android Open Source Project) 이미지
    - google_apis: Google API가 포함된 이미지
    - google_apis_playstore: Google Play 스토어를 포함한 이미지
  • arch: 에뮬레이터의 아키텍처를 설정합니다. (최근의 기기들은 대부분 64비트입니다. 참고 바랍니다.)
    - armeabi-v7a: ARM 32비트 아키텍처.
    - arm64-v8a: ARM 64비트 아키텍처.
    - x86: x86 32비트 아키텍처.
    - x86_64: x86 64비트 아키텍처.
  • profile: 에뮬레이터에 사용할 기기 프로필(기기 이름)을 설정합니다.

이렇게 Flutter 프로젝트에 CI를 적용해 테스트를 자동화해보았습니다. 많은 분들께 도움 되길 바랍니다. 읽어주셔서 정말 감사합니다.

profile
내가 만들어낸 것들로 세계에 많은 가치를 창출해내고 싶어요.

0개의 댓글