Flutter의 Integration test를 CI에 어떻게 도입했는지, 그리고 어떤 문제점을 겪었는지 공유드리려 글을 작성하게 되었습니다.
목차는 다음과 같습니다.
먼저, Flutter의 Integration test가 무엇인지 간단히 비교하며 설명드리겠습니다.
Flutter의 Integration test는 실제 기기나 에뮬레이터에서 실행됩니다. 그렇기 때문에 테스트에 (훨씬)더 많은 시간이 걸리지만 실제 환경에서 테스트해볼 수 있다는 장점이 있습니다.
테스트 종류 | Unit_test, Widget_test | Integration_test |
---|---|---|
목적 | 단일 위젯 테스트, UI와 인터랙션 검증 | 전체 애플리케이션 또는 주요 플로우 테스트 |
범위 | 개별 위젯 또는 특정 화면의 일부 테스트 | 애플리케이션의 전반적인 기능 테스트 |
의존성 | 외부 시스템과 의존성 없음 | 네트워크, 데이터베이스 등 외부 시스템과의 통합 테스트 |
실행 속도 | 비교적 빠름 | 비교적 느림 |
테스트 환경 | 가상 환경 | 실제 디바이스 또는 에뮬레이터 |
사용 패키지 | flutter_test | integration_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분이 넘게 걸렸기 때문에 테스트하고 수정하는데 시간이 오래 걸렸습니다.
(빌드 시간)
(전체 테스트 시간)
이 글을 읽는 분이시라면, 동작하는 아래 코드를 바탕으로 작성하셔서 조금이라도 시간을 절약하실 수 있으면 좋을 것 같습니다.
전체 소스코드는 아래와 같습니다.
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_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
명령어를 실행해 테스트를 실행합니다.
중복되는 부분은 제외하고, 계속 설명을 이어가겠습니다.
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를 적용해 테스트를 자동화해보았습니다. 많은 분들께 도움 되길 바랍니다. 읽어주셔서 정말 감사합니다.