Pocoを利用したUnity製アプリのテスト自動化

# はじめに
去年まではスプレッドシートを利用した自動化をメインにアドベントカレンダーの記事を書いていましたが、今年はPocoを利用したUnity製アプリのテスト自動化を業務で行ったためナレッジのまとめを記事にすることとしました。
この記事をきっかけとして少しでも多くの方がPocoに興味を持っていただければ幸いです。
PocoはAirtestのサブセットとなっているためまずはAirtestとPocoについてお話しします。

# AirtestとPocoについて
## Airtestの概要
Airtestは、NetEaseが提供するオープンソースの自動化テストツールです。
主に画像認識を利用して、画面上の操作を自動化します。特にモバイルゲームやアプリのUIテストに適しており、コードの知識が少なくても使いやすいのが特徴です。
WindowsやAndroid、iOS、Unityエディタなど、多くのプラットフォームをサポートしています。
## Pocoの概要
PocoはAirtestを補完するツールで、アプリのUI要素（ボタン、テキストボックスなど）を直接検出・操作するために設計されています。
ゲームエンジン（UnityやCocos2d）や一般的なモバイルアプリでのテストを、画像認識よりも効率的かつ安定して行えます。
UIツリーに基づいて要素を操作するため、デザイン変更に強いです。

# AirtestとPocoの違い
Airtestは画面上の画像認識をベースにテストを自動化します。解像度やデザインの変更に弱い反面、見た目が重要なゲームやアプリに適しています。
Pocoは、アプリの内部構造（UIツリー）を活用して要素を識別するため、より正確で高速に操作できます。ただし、アプリがカスタマイズしたUIやUIツリーを使用している場合、利用が制限されます。

# AirtestとPocoのメリット・デメリット
## Airtest
<dl>
  <dt>メリット</dt>
  <dd>・クライアントに依存しないためテストの導入が比較的容易</dd>
  <dd>・画像認識でUIの見た目でテスト実装が可能</dd>
  <dd>・アプリの操作やテストログの出力など基本的な機能がAPIとして利用可能</dd>
  <br/>
  <dt>デメリット</dt>
  <dd>・デザイン変更に弱い、画像認識の処理が重い</dd>
</dl>

## Poco
<dl>
  <dt>メリット</dt>
  <dd>・UIツリーを使用するためテスト対象の誤検知が無くテストの安定性が高い</dd>
  <dd>・画像認識と比べて処理が早い</dd>
  <br/>
  <dt>デメリット</dt>
  <dd>・UIツリーを使用するためアプリへのSDKの組み込みが必要</dd>
  <dd>・このため<strong>開発/QAアプリへの組み込みとリリース用アプリでの切り離しが必要になる</strong></dd>
  <dd>・UIツリーが使用できないカスタマイズされたUIを使用している場合には使用できない</dd>
  <dd>・UIツリーでGameObjectを識別するため、表示崩れをテストする場合個別に実装が必要になる</dd>
</dl>


# Airtest・Pocoを利用するための準備
## AirtestIDEのインストール
### [AirtestIDE](https://airtest.netease.com/)
テストスクリプトの実装・実行が簡単に行えるツール。
主にテスト自動化を行う前段階でのアプリのテスト実装行うために使用しています。
pythonでテストスクリプトを記述することになります。

## Airtestパッケージのインストール
前述の通りAirtestはpythonでテストスクリプトを記述します。
AirtestIDEではGUIを使用してスクリプトを実装、実行しますが、ターミナルやコマンドラインからテストスクリプトを実行することが可能です。
テスト自動化のパイプラインにスクリプトの実行を組み込むためにはpythonでAirtestパッケージを使用します。
```
pip install -U Airtest
```

## pocouiのインストール
Airtestで使用される機能はpythonのパッケージで使用されるため、pipを使用してPocoのパッケージをインストールします。
Airtestで使用しているpython、もしくはAirtestIDEで使用するpythonを選択することも可能なので使用するpythonに合わせてインストールします。
```
pip install -U pocoui
```
## PocoSDKの組み込み
[PocoSDK](https://github.com/AirtestProject/Poco-SDK)
UnityのAsset以下に配置します。最近は配置する場所に関する制約はゆるくなったとのことですが、慣習に従って「Assets/Scripts/」へUnity3Dを追加します。
次にUnityで使用しているUIのタイプにより、ugui、ngui、failyguiの中から一つ選択します。(使用するもの以外は削除)
一般的にビルドは開発用とリリース用で分けることが多いですが、#if文を使用してPocoMangerを使用するかどうかを制御するのが比較的簡単です。
意図せず公開用のビルドにPocoが組み込まれてしまったりすると容易にUIツリーの取得や操作を自動化されてしまう恐れがあるので気をつけましょう。

Pocoを利用するまでの準備はここまでです。
続いて今回Pocoでのテスト実装の説明に使用するサンプルアプリの説明をします。
# サンプルで使用するUnityアプリの説明
今回Pocoを利用したテストの実装例を説明する際に使用するサンプルで使用するアプリの内容です。

サンプルで使用するシーンは二つで、ログイン画面とログイン完了画面です。

ログイン画面ではユーザーIDとパスワードを入力しログインボタンをタップします。
ユーザーIDとパスワードが正しい組み合わせの場合、ログイン完了画面へ遷移します。
間違った組み合わせの場合、エラーメッセージが表示されます。

ログイン完了画面では戻るボタンをタップすることでログイン画面へ遷移します。

### パッケージ名
| パッケージ名 | 役割 |
|:-----------|:-----------|
| com.example.login| Androidで起動時などに使用|

### シーン名
| シーン名 | 役割 |
|:-----------|:-----------|
| LoginScene| ユーザーIDとパスワードの確認を行う|
| CompleteLoginScene| ログイン完了画面|

### ログイン画面

<img width="270" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/423369/df78f6cc-3d5f-2259-192f-b0bf3f3890f2.png">
<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/423369/ca3a2df0-3055-c6b7-f08f-bd96912ace09.png">

| GameObject名 | 型 | 役割 |
|:-----------|:-----------|:------------|
| UserIDInput| TMP_InputField| ユーザーIDを入力|
| PasswordInput| TMP_InputField| パスワードを入力|
| SubmitButton| Button| 入力された内容で決定|
| ErrorMessageText| TextMesh Pro| エラーメッセージを表示|
| UIManager| GameObject| LoginManager.csをアタッチする空のGameObject|

ここではサンプルとして使用するため、正しいUserIDとPasswordをそれぞれLoginManagerクラスのvalidUserIdとvalidPasswordにstringでセットしています。
SubmitButtonのonClickにOnLoginButtonClickをセットして、SubmitButtonがクリックされたタイミングで入力されたUserIDとPasswordが予め設定しておいたvalidUserIdとvalidPasswordと一致するかを判別します。
一致する場合にはログイン完了画面へ、一致しない場合にはエラーメッセージを表示する実装になっています。
またDEVELOPMENT_BUILDが有効な場合PocoManagerをインスタンス化します。

```csharp:LoginManager.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

using UnityEngine.SceneManagement;
using TMPro;
using UnityEngine.UI;

#if DEVELOPMENT_BUILD
using Poco;
#endif

public class LoginManager : MonoBehaviour
{
    public TMP_InputField userIdField;
    public TMP_InputField passwordField;
    public GameObject errorMessage;
    public Button SubmitButton;

    private string validUserId = "test";
    private string validPassword = "test";

    void Start()
    {
#if DEVELOPMENT_BUILD
        new PocoManager();
        Debug.Log("PocoSDK Initialized (Development Build)");
#endif

        errorMessage= GameObject.Find("ErrorMessageText");
		errorMessage.SetActive(false);
		userIdField = GameObject.Find("UserIDInput").GetComponent<TMP_InputField>();
		passwordField = GameObject.Find("PasswordInput").GetComponent<TMP_InputField>();
		SubmitButton = GameObject.Find("SubmitButton").GetComponent<Button>();
		SubmitButton.onClick.AddListener(this.OnLoginButtonClick);
    }

    public void OnLoginButtonClick()
    {
        string enteredUserId = userIdField.text;
        string enteredPassword = passwordField.text;

        if (enteredUserId == validUserId && enteredPassword == validPassword)
        {
            SceneManager.LoadScene("CompleteLoginScene");
        }
        else
        {
            errorMessage.gameObject.SetActive(true);
        }
    }
}
```
### ログイン完了画面

<img width="270" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/423369/c884047e-6e50-f9a7-f17c-048f76332397.png">
<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/423369/e0756774-5639-04a4-e61f-5a1682f5fe4b.png">

| GameObject名 | 型 | 役割 |
|:-----------|:-----------|:------------|
| ReturnLoginButton| Button| ログイン画面に戻る|
| UIManager| GameObject| CompleteLoginManager.csをアタッチする空のGameObject|

```csharp:CompleteLoginManager.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

using UnityEngine.SceneManagement;
using UnityEngine.UI;

public class CompleteLoginManager : MonoBehaviour
{
    public Button ReturnLoginButton;

    void Start()
    {
		ReturnLoginButton = GameObject.Find("ReturnLoginButton").GetComponent<Button>();
		ReturnLoginButton.onClick.AddListener(this.OnReturnLoginButtonClick);
    }

    public void OnReturnLoginButtonClick()
    {
		SceneManager.LoadScene("LoginScene");
    }
}
```
今回のテストの実装に関係がないため詳細は省きますが、ReturnLoginButtonをクリックされたときにOnReturnLoginButtonClickが呼び出され、ログイン画面に戻ります。

# Pocoを利用したテスト実装例
ここでは先ほど説明したサンプルアプリを使って、スモークテストの実装の例をあげていきます。
スモークテストではアプリの起動からログイン画面でログインが完了するまでの操作を実装します。

<strong>アプリの起動</strong>
1\. アプリを起動する
2\. ログイン画面で使用する「UserIDInput」オブジェクトが存在すれば成功

<strong>ログイン画面</strong>
1\. UserIDInputをクリック
2\. ソフトウェアキーボードが表示されるのでusernameで指定される文字列を入力
3\. PasswordInputをクリック
4\. ソフトウェアキーボードが表示されるのでpasswordで指定される文字列を入力
5\. SubmitButtonをクリック
6\. ログイン完了画面で表示される「CompleteLoginText」オブジェクトが存在すれば成功


## スモークテストの実装

```python:poco_test.py
from airtest.core.api import *
from poco.drivers.unity3d import UnityPoco
import time
from airtest.core.settings import Settings
from airtest.report.report import simple_report

# デバイス接続
device_serial = "123456789"  # 接続したいデバイスのシリアルID(adb devicesなどで調べる)
connect_device(f"Android:///{device_serial}")

#アプリID (パッケージ名)
APP_PACKAGE = "com.example.login"

#ログファイルの名前を設定
Settings.LOG_FILE = "log.txt"

#ログ出力のディレクトリを設定
set_logdir(r'Log')

# Pocoインスタンス作成
poco = UnityPoco()

#アプリの起動テスト
def test_app_launch():
    start_app(APP_PACKAGE)
    time.sleep(3)  # アプリ起動待機

    # スプラッシュ画面確認
    assert poco("UserIDInput").exists(), "UserIDInput not found!"
    print("App launched successfully!")

#ログインテスト
def test_login(username, password):
    poco("UserIDInput").click()
    text(username)

    poco("PasswordInput").click()
    text(password)

    time.sleep(3)  # 入力待機
    
    poco("SubmitButton").click()
    
    # ログイン成功確認
    assert poco("CompleteLoginText").exists(), "Login failed!"
    print("Login successful!")

# テストの実行
if __name__ == "__main__":
    try:
        # テストシナリオ実行
        test_app_launch() #アプリの起動
        test_login("test", "test") #ユーザー入力のUserIDとPassword
        print("Smoke test completed successfully!")
    except AssertionError as e:
        print(f"Test failed: {e}")

    #出力されたログをhtmlの形式で出力する
    simple_report(__file__,logfile=r"log.txt",output=r"html/log.html")

```
## スモークテストの実行の準備
```
projectRoot
├─ Log
|  └─...
├─ html
│  └─... 
└─ poco_test.py
```
poco_test.pyをそのまま実行してもスクリプト内で参照するディレクトリが存在しないためエラーになります。
そのため上記のような構成になるように、予めLogディレクトリとhtmlディレクトリを作成しておきます。
## スモークテストの実行
作成したスクリプトを実行します。
```
cd projectRoot
airtest run poco_test.py
```
# テストのログとレポート
poco_test.pyの実装で、全てのテストが終了した後に出力されたログをhtmlとして出力し直す処理が実装されています。
出力先は「projectRoot/html/log.html」です。
そのままダブルクリックで開くとブラウザでテストレポートが表示されます。
またWebServerを設置して出力したhtmlへのURLを共有することで閲覧することも可能になります。

![スクリーンショット 2024-12-13 0.57.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/423369/8b9aaf2b-2acb-93ef-4dfe-a42038080192.png)

![スクリーンショット 2024-12-13 0.57.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/423369/24bcbc4d-60f3-44f0-0c24-8d1c91d2049a.png)

# まとめ
今回は
* AirtestとPocoについての概要
* AirtestとPocoのメリットとデメリット
* AirtestとPocoを利用するための準備
* サンプルアプリ
* スモークテストの実装・実行
* テストのログ・レポート

という内容で、ざっくりとUnity製のアプリでの実装内容とPocoを使ってどのようにスモークテストの自動化を行うかについて一通りサンプルを交えながら解説しました。

これまでは画像認識でのテスト自動化を行ってきましたが、Pocoを利用することでUIツリーを利用したテスト自動化も可能になりました。
双方にメリットとデメリットがあるためどちらかと選択できるものではなく相互にメリットを活かし、デメリットを減らすという考え方が今後必要になります。

Airtestのみを使う場合ではテスト対象を画像で識別できないことにによりテストを継続することが困難になりますが、Pocoを併用することにより画像認識で失敗したとしもテスト結果を残しつつUIベースでのテストが継続できるため、網羅的なテストが可能となります。

Pocoを併用する場合、特にセットアップの複雑さや学習コストの面でAirtest単体での使用と比べてデメリットはありますが、動的な要素への対応や速度、メンテナンス性におけるメリットは大きいため、デメリットを減らすためのナレッジとどのようにしてナレッジを横展開していくかが今後Pocoの導入を広めていくカギになると考えています。

今回の記事が自動化を進めたいと思っている方の一助になれば幸いです。

おわり