OpenCVを使ってAppiumで特定しにくい要素に触ってみる

全国一千万人の自動テストエンジニアの皆さま、こんにちは。
viviONのSETエンジニアのFuzicueです。

## はじめに
viviONでは現在数種類のアプリケーションを展開しており、その開発言語・使用ライブラリ・手法はチーム毎に様々です。
SETチームではスマホアプリのE2Eテストを実装する際にAppiumを使用していますが、開発の都合上、リリース版のビルドで要素取れない！なんてこともしばしばあります。

viviONのSETチームでは、開発者に負担をかけずどんな開発手法にも対応できるようノウハウを蓄積してきました。

## 誰に向けてるのか
- Appiumで要素が取れない項目に対して操作を行いたい人

## やったこと
実際のコードは以下の通りです。

```python
import cv2
from appium import webdriver

# Appiumの設定
def start_appium():
    desired_caps = {
        "platformName": "Android",
        "deviceName": "device_name",
        "automationName": "UiAutomator2"
    }
    driver = webdriver.Remote("http://localhost:4723", desired_caps)
    return driver

def take_screenshot(driver, file_name):
    driver.save_screenshot(file_name)

    
# OpenCVでテンプレートマッチングを実行
def find_button_coordinates(screenshot_path, template_path):
    # 探索対象画像読み込み（オリジナル＝カラーあり）
    screen = cv2.imread(screenshot_path)
    # 探索対象画像のグレースケール化
    gray = cv2.cvtColor(screen, cv2.COLOR_BGR2GRAY)
    # テンプレート画像の読み込み（グレースケール画像として）
    template = cv2.imread(template_path, 0)
    w, h = template.shape[::-1]
    # 各比較方法によるマッチング
    tmp_gray = gray.copy()
    method = eval('cv2.TM_CCOEFF_NORMED')
    # マッチング自体グレースケール化した画像同士で行う
    res = cv2.matchTemplate(template, tmp_gray, method)
    min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(res)
    # TM_SQDIFF・TM_SQDIFF_NORMEDの場合は最小を取る
    top_left = max_loc
    bottom_right = (top_left[0] + w, top_left[1] + h)
    return [max_val,top_left,bottom_right]

# 指定された座標をAppiumでクリック
def click_coordinates(driver, coordinates):
    # テンプレートマッチングの結果を受け取る
    # ボタンの中心座標を計算
    x = int(coordinates[1][0]+coordinates[2][0])/2 
    y = int(coordinates[1][1]+coordinates[2][1])/2 
    driver.tap([(x, y)])  # タップする座標を指定



if __name__ == "__main__":
    # ボタンの画像
    template_path = "button_template.png"

    driver = start_appium()
    screenshot_path = "current_screen.png"
    take_screenshot(driver, screenshot_path)
    # OpenCVでテンプレートマッチング
    coordinates = find_button_coordinates(screenshot_path, template_path)
    click_coordinates(driver, coordinates) 
    driver.quit()
```

## 解説
### start_appium
Appiumを使用してAndroidデバイスに接続するための設定を行い、ドライバーを呼んできます。
 - desired_caps
Androidデバイスと自動化の設定を指定する辞書
 - platformName
プラットフォームの名前を入力（Androidの場合はAndroidでOK）
 - deviceName
デバイスの識別名を入力してください。（androidの場合adb devicesで取得できます）
 - automationName
自動化エンジン（androidの場合はUiAutomator2）

 - webdriver.Remote
Appiumサーバーに接続して、指定されたデバイスに操作を送るためのドライバーを作成します。

**戻り値**
Appiumのドライバーオブジェクト

### take_screenshot
デバイス画面のスクリーンショットを保存します。
動作:
 - driver.save_screenshot(file_name)
    デバイスの現在の画面を指定したパスに画像ファイルを保存

### find_button_coordinates 

OpenCVを使って、スクリーンショット内で指定されたボタン（テンプレート画像）の位置を特定します。
 - cv2.imread
スクリーンショット画像とテンプレート画像を読み込む。
 - cv2.cvtColor
スクリーンショット画像をグレースケール化
 - cv2.matchTemplate
テンプレートマッチングを実行して、テンプレート画像がスクリーンショット内で一致する場所を探します。
 - cv2.TM_CCOEFF_NORMED
マッチング方法（正規化相関係数）を指定しています。
 - cv2.minMaxLoc
マッチング結果から一致度の最大値（max_val）と位置（max_loc）を取得

**戻り値**
マッチングスコア（max_val）、左上座標（top_left）、右下座標（bottom_right）をリストとして返します。


## まとめ
これらを組み合わせることで、簡単にAppiumで要素が取れない項目にもアクセスできるようになりました。
Appiumだけで実装したときと違いOpenCVが動作している時間が挟まるため、動作は若干遅くなります。
ゲームなど、リアルタイム性が求められる項目にはあまり向かないとは思いますが、一般的なアプリケーションの項目を動かすのであれば十分だとは思います。

iOSの場合は、スクリーンショットと画面の大きさが合わず、比率で出したりしないといけないなど、制約は様々ありますが概ね同様の実装で動きます。
*その話はまた別の機会に……。*

同様のTemplate matchを利用したライブラリにAirtestがありますが、既にAppiumで実装をしてしまっているテストに追加する場合など、参考になればと思いここに残しておきます。

## 参考
https://dev.classmethod.jp/articles/search-images-in-an-image-with-opencv-python/

## 採用情報
viviONでは一緒に働いてくれるSETエンジニア・QAエンジニアを募集しています。
二次元コンテンツやDLsiteが好きなオタクテストエンジニアの皆さん、一緒に働きませんか？

https://vivion.jp/recruit/