Akatsuki Hackers Lab | 株式会社アカツキ(Akatsuki Inc.)

Akatsuki Hackers Labは株式会社アカツキが運営しています。

AWS Device Farm で Airtest を動かす方法

AWS Device Farmとは?

https://docs.aws.amazon.com/ja_jp/devicefarm/latest/developerguide/welcome.html

Device Farm は、アマゾン ウェブ サービス (AWS) によりホストされている実際の物理的な電話やタブレットで、Android や iOS、およびウェブアプリケーションをテストしてやり取りできるアプリケーションテストサービスです。

  • フレームワークを使用したアプリケーションの自動テスト
  • リアルタイムリモートアクセス

の二つのサービスがあり、今回は前者の自動テストについての内容です
自動テストサービスの一連の流れは以下の記事がわかりやすいです
AWS Device Farm で Appium を使って Androidネイティブアプリの自動テストを試してみた

対応フレームワークは以下があります

  • Appium
  • Calabash
  • Instrumentation(Android)
  • UI Automator(Android)
  • UI Automation(iOS)
  • XCTest(iOS)

Airtest とは?

https://airtest.netease.com/

With image recognition and UI hierarchy, our automation framework support game testing (訳: 画像認識とUI階層化により、ゲームテストをサポートする自動化フレームワークです。)

CEDEC2020ではその利用例が紹介されています
AirtestとPocoとOpenSTFによるUnity製スマートフォン向けゲームの実機自動テスト環境構築とその利用方法

一般的なモバイルアプリケーションの自動化は、アクセシビリティや自動化がサポートされたUI要素を指定しアクションを行います
しかし、ゲーム(正確にはゲームエンジン)のUI要素はレンダリングエンジンが描画したただのピクセルなため、外部スクリプトから直接UI要素を指定することができません
そのため、画像認識や内部にエージェントを組み込むなどしてUIを指定します

AWS Device Farmでサポートされているフレームワークの中ではAppiumが画像認識でのテストをオプションでサポートしています。しかし、Device Farmでは現在実行することができません(2022/06時点で確認済み)

AWS Device Farm 上で Airtest を動かすには

公式にサポートはされていないフレームワークでも、YAML 形式のテスト仕様 (テストスペック) にスクリプトを書くことで実行することが可能です

対応フレームワークに見せかける

テスト一式(test_bundle.zip)をアップロードする際にはフレームワークごとの形式に沿っている必要があるので、Appium Python と認識されるために必要最低限の体裁を整えます

構成例(pytestっぽくする)

.
├── requirements.txt(Appiumが含まれたもの)
└── tests(「tests」という名前のディレクトリ)
    ├── smoke.py(任意のAirtestファイル)
    └── test_main.py(test_hoge or hoge_test という形式の名前.py)

test_main.pyはこのようにAirtestのファイルを呼び出すようにします

import sys

def run_test():
    import smoke  # Run top-level commands

print("[TEST_SCRIPT]Start Airtest", file=sys.stderr) # Device Farmで標準出力がバッファリングされて見にくいのでstderrへ
run_test()
print("[TEST_SCRIPT]Finish Airtest", file=sys.stderr)
# 起動確認程度の簡単なスモークテスト
from airtest.core.api import *

CWD = os.getcwd()
PKG = os.environ['APP_PACKAGE']
LOG_DIR = os.environ['LOG_DIR']
auto_setup(__file__, logdir=LOG_DIR, devices=["Android:///", ])

if PKG not in device().list_app():
    install(CWD + "/" + os.environ['APP_PATH'])
start_app(PKG)

assert_exists(Template(r"tpl0000000000.png", "スタート画面があること")

uninstall(PKG)

テストスペックは以下のようにします

version: 0.1

phases:
  install:
    commands:
      - export PYTHON_VERSION=3
      - cd $DEVICEFARM_TEST_PACKAGE_PATH
      - . bin/activate
      - pip install --upgrade pip
      - pip install -r requirements.txt

  pre_test:
    commands:
      - cd $DEVICEFARM_TEST_PACKAGE_PATH
      - echo "Pre Test. OK"

  test:
    commands:
      - echo "Test Start"
      - export APP_PACKAGE='jp.hoge.huga.dev'
      - export LOG_DIR=$DEVICEFARM_LOG_DIR
      - python3 tests/test_main.py
      - echo "Test Done"

  post_test:
    commands:
      - echo "Post Test. OK"

artifacts:
  - $DEVICEFARM_LOG_DIR

しかし、このままではエラーで終了してしまいます

adb server version (39) doesn't match this client (40); killing...
ADB server didn't ACK

どうやらadbがDevice Farmのホストマシン上のものとAirtest内蔵のものでversionが異なり、既に起動しているDevice FarmのものをAirtest側がkillしようとして失敗するようです

adbをホストマシンのに差し替える

AirtestのFAQによると、競合した際はpackage内のadbを任意のものに差し替えるとのことです
なのでtestspecのpre_testでinstallしたAirtest package内のadbをDevice Farmのものに差し替えます

~略~

  pre_test:
    commands:
      - cd $DEVICEFARM_TEST_PACKAGE_PATH
      # Airtest package 内の adb を Device Farm のものに差し替える
      - echo "adb setup start"
      - rm ./lib/python3.7/site-packages/airtest/core/android/static/adb/linux/adb
      - $adb_path=$(which adb)
      - cp adb_path ./lib/python3.7/site-packages/airtest/core/android/static/adb/linux/adb
      - sed -i -e s#^\$ADB#$adb_path.orig#g ./lib/python3.7/site-packages/airtest/core/android/static/adb/linux/adb

      - echo "Pre Test. OK"

~略~

これでAirtestが動作するようになりますが、今度はテストシナリオは終了しているのに (testspecでいう)post_testに進行しなくなります
test 内 python3 tests/test_main.py 後の echo "Test Done" すら実行されていません. (uninstallや終了処理はされているようです)

[DEBUG]<airtest.core.android.adb> /tmp/scratchP4PNu8.scratch/test-packageXI1AKZ/lib/python3.7/site-packages/airtest/core/android/static/adb/linux/adb -s XXXXX uninstall 'jp.hoge.huga.dev
[DEBUG]<airtest.core.android.adb> /tmp/scratchP4PNu8.scratch/test-packageXI1AKZ/lib/python3.7/site-packages/airtest/core/android/static/adb/linux/adb -s XXXXX forward --remove tcp:14967
[07:16:55][DEBUG]<airtest.utils.nbsp> [rotation_server]b''
[07:16:55][DEBUG]<airtest.core.android.adb> /tmp/scratchP4PNu8.scratch/test-packageXI1AKZ/lib/python3.7/site-packages/airtest/core/android/static/adb/linux/adb -s XXXXX forward --remove tcp:18833

終了できていないプロセスを強制終了する

Airtestでは自動操作や画面のキャプチャのためにAndroid側やAirtestを実行しているマシンにいくつかのアプリケーションをインストールして、最後に終了するようになっています
その中の rotationwatcher という端末の回転を検知するプロセスがうまく終了できていなさそうでした

Airtest側でうまく終了できなそうだったので、強引ですがプロセスをkillする処理をAirtestの終了処理に差し込むことでDevice Farmが正常に終了しました

import sys

def register_force_kill():
    import time
    import subprocess

    def run_shell(cmd):
            print(cmd, file=sys.stderr)
            process = subprocess.Popen(cmd, stderr=subprocess.PIPE, stdout=subprocess.PIPE, shell=True)
            stdout, stderr = process.communicate(timeout=150)
            exit_code = process.returncode
            print("stdout: ", stdout, " stderr: ", stderr, " exit_code: ", exit_code, file=sys.stderr)

    def teardown():
        print("[TEST_SCRIPT]teardown", file=sys.stderr)
        #run_shell("ps aux | grep rotationwatcher") # 確認用
        run_shell("pkill -f rotationwatcher")
        time.sleep(1) # Wait for rotationwatcher to terminate
        #run_shell("ps aux | grep rotationwatcher") # 確認用

    from airtest.utils.snippet import reg_cleanup
    reg_cleanup(teardown) # Insert into airtest termination process


def run_test():
    import smoke  # Run top-level commands

print("[TEST_SCRIPT]Registration of forced termination", file=sys.stderr)
register_force_kill()
print("[TEST_SCRIPT]Start Airtest", file=sys.stderr)
run_test()
print("[TEST_SCRIPT]Finish Airtest", file=sys.stderr)

無理やりkillしてるので本来の終了プロセスで参照してエラーがでていますが、 例外は無視されていてDevice Farm自体は正しく動いています

Exception ignored in: <module 'threading' from '/usr/local/lib/python3.7/threading.py'>
Traceback (most recent call last):
  File "/tmp/scratchnIe8tr.scratch/test-package8h1e4P/lib/python3.7/site-packages/airtest/utils/snippet.py", line 92, in exitfunc
    _cleanup()
~略~
ValueError: Invalid file object: <_io.BufferedReader name=7>

動画やログ、成果物の保存なども正常にできています

Device Farm の画面
ダウンロードしたレポート

まとめ

  • 対応フレームワークに見せかけて
  • AirtestのadbをDevice Farmのものに差し替えて
  • 終了に失敗するプロセスを自前で止めることで

AWS Device Farm上でAirtestが動きました! (サポート外の方法かつ無理やりな部分もあるのであくまで一例として…)