見出し画像

PlaywrightでAndroidネイティブアプリのテストを自動化!?実際に試してみて分かった衝撃の事実!


はじめに

SHIFTで自動化アーキテクトをやっている片山 嘉誉です。

AndroidのネイティブアプリをPlaywrightを使って操作できないか検証を行った際、色々罠があったのでここにその内容を残しておきたいと思います。

PlaywrightはWebアプリケーションの自動化を行うツールというイメージが強いですが、Androidアプリの操作全般にも対応しています。

但し、あまり流行っていないのか日本語の情報が少なく、この記事が誰かの役に立てばと思っています。

なお、ここではPlaywrightが動作する環境は構築済みである前提とし、動作確認としてAndroid Virtual Device(AVD)を使用しますが、こちらもセットアップ方法などは割愛しますので各自ご対応お願いします。(もちろん実機を使って検証しても良いです)

PlaywrightでAndroidネイティブアプリを操作するやり方について

まず、どのような仕組みでPlaywrightによりAndroidネイティブアプリを操作するかというと、2023年9月時点ではまだExperimental(実験的)なAPIとして公開されているAndroidクラスというものがあり、これを使用してAndroidネイティブアプリの操作を行います。

公式ページにはサンプルの実装も載っていますが、Androidネイティブアプリの操作を行う部分を抜粋すると以下になります。

  // Fill the input box.
    await device.fill({
      res: 'org.chromium.webview_shell:id/url_field',
    }, 'github.com/microsoft/playwright');
    await device.press({
      res: 'org.chromium.webview_shell:id/url_field',
    }, 'Enter');

上記の「fill」が文字入力、「press」がキー押下を行う処理になるのですが、これらの操作はAndroidDeviceクラスを使うことで実現されており、リファレンスを見るとかなり多様な操作に対応しているようです。

引数に「selector(AndroidSelector)」を指定するようになっていますが、上記サンプルの場合はAndroidアプリ内に付与されているリソースIDとなっており、テキストボックスやボタンのリソースIDを指定する事で対象のオブジェクトに対して操作を行う事ができます。

※Androidアプリの各オブジェクトに付与された情報を取得する方法については本記事の後半で紹介します。

テスト環境の作成について

Androidアプリの操作に何か特別なモジュールのインストールが必要かというと、そのようなことはありません。

Playwrightの公式サイトに書いてあるインストール手順を実施すれば、特にそれ以上のことは不要です。

npm init playwright@latest

但し、これだと正直不要なものもたくさん入ってしまうので(特にブラウザの設定など)、今回は最低限必要なplaywright-coreと後でテスト環境として整備しやすいように@playwright/testだけ入れて検証を進めたいと思います。

■環境構築用のコマンド

npm i playwright-core
npm i @playwright/test
mkdir tests

■初期状態のプロジェクト構成

project
├ node_modules
│ └ インストールしたモジュール達
├ tests
│ └ ここにスクリプトを配置
├ package.json
└ package-lock.json

では、公式サイトのサンプルを基に私の方でスクリプトを作ったので、それを使って動作確認をしていきます。

「tests」フォルダ配下に以下のスクリプトを配置してください。

■tests/android.spec.tsファイル

// android.spec.ts
import { test, _android } from '@playwright/test';

test('Android自動化サンプルスクリプト', async () => {
  // デバイスに接続する
  const [device] = await _android.devices();
  // デバイスのモデル名をログ出力する
  console.log(`Model: ${device.model()}`);
  // デバイスのシリアル番号をログ出力する
  console.log(`Serial: ${device.serial()}`);
  // デバイス全体のスクリーンショットを撮る
  await device.screenshot({ path: 'device.png' });

  {
    // ----- WebViewが内包されたアプリの自動化 -----
    // ブラウザアプリを強制的に停止する
    await device.shell('am force-stop org.chromium.webview_shell');
    // ブラウザを開始する
    await device.shell('am start org.chromium.webview_shell/.WebViewBrowserActivity');
    // WebViewを取得する
    const webview = await device.webView({ pkg: 'org.chromium.webview_shell' });

    // URL入力フィールドにURLを入力する
    await device.fill({
      res: 'org.chromium.webview_shell:id/url_field'
    }, 'github.com/microsoft/playwright');
    // URL入力フィールドでEnterキーを押すことでページ移動する
    await device.press({
      res: 'org.chromium.webview_shell:id/url_field'
    }, 'Enter');

    // WebViewのページを取得する
    const page = await webview.page();
    // ページが指定したURLに移動するまで待つ
    await page.waitForNavigation({ url: /.*microsoft\/playwright.*/ });
    // ページのタイトルをログ出力する
    console.log(await page.title());
  }

  {
    // ----- Chromeブラウザの自動化 -----
    // ブラウザアプリを強制的に停止する
    await device.shell('am force-stop com.android.chrome');
    // Chromeブラウザを起動する
    const context = await device.launchBrowser();

    // 新規ページを作成する
    const pageChrome = await context.newPage();
    // 指定したURLに移動する
    await pageChrome.goto('https://webkit.org/');
    // ページのURLをログ出力する
    console.log(await pageChrome.evaluate(() => window.location.href));
    // ページのスクリーンショットを撮る
    await pageChrome.screenshot({ path: 'page.png' });

    // Contextを閉じる
    await context.close();
  }

  // デバイスとの接続を閉じる
  await device.close();
});

上記は前半がWebViewを内包したアプリの操作で、今回検証を行うネイティブアプリの操作に該当します。

後半はChromeブラウザの操作になるので今回の話とは違うのですが、参考に動作するかやってみて貰えたらと思います。

AVDでエミュレータを起動した状態で以下のコマンドでテストを実行すると、アプリが立ち上がり指定したページを開くという操作が行われることが確認できると思います。

■実行結果

PS C:\work\playwright_android> npx playwright test

Running 1 test using 1 worker

  ✓  1 tests\android.spec.ts:3:5 › Android自動化サンプルスクリプト (23.7s)
Model: sdk_gphone_x86_64_arm64
Serial: emulator-5554
GitHub - microsoft/playwright: Playwright is a framework for Web Testing and Automation. It allows testing Chromium, Firefox and WebKit with a single API.
https://webkit.org/

  Slow test file: tests\android.spec.ts (23.7s)
  Consider splitting slow test files to speed up parallel execution
  1 passed (24.3s)
PS C:\work\playwright_android>

因みに余談ではありますが、作成したスクリプトを個別で実行する場合、Windowsだと「\」マークを2重にしないと実行できないので要注意です。

PS C:\work\playwright_android> npx playwright test .\\tests\\android.spec.ts

AndroidSelectorという謎の存在について

さて、PlaywrightによるAndroidアプリのテスト自動化がどういったものかざっくり分かったところで、今回一番困ったAndroidSelectorについて話をして行きたいと思います。

AndroidSelectorはAndroidDeviceクラスの各メソッドの引数(selector)で使用するアプリ内のオブジェクトを指定するためのもので、例えばリファレンスでtapなどを見ると引数のselectorで指定したものをタップする旨が記載されています。

tap
Added in: v1.9
  Taps on the widget defined by selector.

Usage
  await androidDevice.tap(selector);
  await androidDevice.tap(selector, options);

Arguments
  selector [AndroidSelector]

  Selector to tap on.

では、どのような指定ができるのか、という話なのですが、これが公式サイトや検索サイトでいくら探しても全く見つけることができませんでした…。

実はサンプルにある「res」以外にも様々な種類の指定方法があるのですが、恐らく実装はされているものの2023年9月時点では使い方を説明するドキュメントが存在していないものと思われます。

そこでどのような定義があるのかGithubにあるPlaywrightのソースコードを確認し、以下の種類が存在していることが確認できました。

AndroidSelectorの一覧

export type AndroidSelector = {
  checkable?: boolean,
  checked?: boolean,
  clazz?: string | RegExp,
  clickable?: boolean,
  depth?: number,
  desc?: string | RegExp,
  enabled?: boolean,
  focusable?: boolean,
  focused?: boolean,
  hasChild?: { selector: AndroidSelector },
  hasDescendant?: { selector: AndroidSelector, maxDepth?: number },
  longClickable?: boolean,
  pkg?: string | RegExp,
  res?: string | RegExp,
  scrollable?: boolean,
  selected?: boolean,
  text?: string | RegExp,
};

リソースIDを示す「res」以外にも、オブジェクトに付与されているテキストを示す「text」や、各オブジェクトの状態を示すものまで多岐にわたるようです。

残念ながら上記それぞれがどのように使うものかの説明はソースコード上にもなかったのですが、Androidアプリを触ったことがある方であれば何となく想像ができるのではないかと思います。(というのも、この後説明するUI Automator ビューアでも上記の状態が確認できるため)

また、この指定は複数組み合わせて対象を絞ることもできるので、同じオブジェクトだけどテキストやアイコンが違う場合など、色々条件を絞って指定することができそうです。

    // スタートボタンをタップする
    await device.tap({
      res: 'com.google.android.deskclock:id/fab',
      desc: 'Start'
    },);

AndroidElementInfoというもう一つの謎の存在について

リファレンスを見ているとinfoの戻り値に「AndroidElementInfo」という記載があります。

info
Added in: v1.9
  Returns information about a widget defined by selector.

Usage
  await androidDevice.info(selector);

Arguments
  selector [AndroidSelector]

Selector to return information about.

Returns
  Promise<[AndroidElementInfo]>

これもAndroidSelectorと同様に何の説明も無いのですが、ソースコードを見ると以下のようになっていました。

AndroidElementInfoの一覧

export type AndroidElementInfo = {
  clazz: string;
  desc: string;
  res: string;
  pkg: string;
  text: string;
  bounds: { x: number, y: number, width: number, height: number };
  checkable: boolean;
  checked: boolean;
  clickable: boolean;
  enabled: boolean;
  focusable: boolean;
  focused: boolean;
  longClickable: boolean;
  scrollable: boolean;
  selected: boolean;
};

早い話がselectorで指定したオブジェクトのプロパティが入っているもの、ということになり、例えば以下のようにオブジェクトの状態によってアサーションを追加するのに使えそうです。

    // タイマーに設定されている時間を取得する
    const timer_setup_time_promise = await device.info({
      res: 'com.google.android.deskclock:id/timer_setup_time'
    });
    // 初期値であることを確認する
    expect(timer_setup_time_promise.text).toContain('00h 00m 00s');

AndroidDevice#infoの注意点について

infoなのですが、どうも指定したオブジェクトにtextとdescの両方が入っていないとエラーになってしまうようです。

■textが無い場合
Error: androidDevice.info: info.text: expected string, got undefined

■descが無い場合
Error: androidDevice.info: info.desc: expected string, got undefined

恐らく表示されている文字列を確認するために用意されたものなのでしょうが、テキスト以外の情報を見たい場合に使えないのは正直使い勝手が悪いですね…。

Androidアプリの各オブジェクトに付与された情報を取得する方法について

ここからはエミュレータにプリインストールされている時計アプリを使ってアプリ内に配置された各オブジェクトの情報を取得してみたいと思います。

Androidアプリの各オブジェクトの情報を取得する方法としてAppiumであればAppium Server GUIAppium Inspectorを使ったやり方がありますが、どうもこのアプリが動作している間はPlaywright側でAndroidDeviceクラスによる操作ができない(他の処理は実行できる)ようです。

そこで別の方法で取得する必要があるのですが、今回はUI Automator ビューアを使っていきたいと思います。

Android SDKをインストールしている場所にもよるので実行するためのバッチの場所は各自のPCに依存しますが、私の場合は以下にあるバッチを実行しました。

C:\Users\[ユーザ名]\AppData\Local\Android\Sdk\tools\binuiautomatorviewer.bat

起動すると以下のような画面が出てくるのですが、左上の方にあるドロイド君のアイコンを押すと起動しているアプリの画面を取得することができます。

適当なオブジェクトを選択すると画面右側にそのオブジェクトの詳細な情報が出てくるのですが、「enabled(可視状態か)」や「focused(選択状態か)」などの状態がどうなっているかも確認することができます。

今回はタイマーを使ったテストスクリプトを作って行きたいので画面上部にあるタイマーのアイコンをタップしたいのですが、このアイコンにはリソースIDが付与されていないのでテキスト(text)を使って指定するのが良さそうです。

UI Automator ビューアでエラーがでる場合について

以下のエラーが出た場合はadbを再起動させるとうまく接続できるようになります。

■エラーが発生した場合のメッセージ

Error while obtaining UI hierarchy XML file: com.android.ddmlib.SyncException: Remote object doesn't exist!

■対処方法(adbの再起動)

adb kill-server
adb start-server

それでもダメな場合は(AVDによる端末の一時停止ではなく)エミュレータ上の電源ボタンを長押しして端末を一度パワーオフ状態にし、そこから再度エミュレータを起動すると復帰します。(ちょっとこの辺り不安定ですね…)

また、これはエミュレータの問題かもしれませんが、描画が頻繁に更新される以下のような画面だとうまく情報が取得できずエラーが起きてしまうようで、この場合どうしようも無いため「Appium Inspector」を使った方が良さそうです。

ここが結構ポイントなのですが、上記のような情報が取得できずエラーとなってしまう画面はPlaywrightの動作も不安定となり、selectorで指定したオブジェクトをなかなか認識してくれません…。(実際にタップされるまで時間が掛かります)

そのため、エラーとなってしまう画面に対する操作には難があることを覚えておいた方が良いでしょう。

時計アプリを題材にしたテストスクリプトについて

取得したオブジェクトの情報を組み合わせて、時計アプリで5秒のタイマーをセットしてから鳴動後のタイマーを停止させるスクリプトを作成してみました。

冒頭で作成したandroid.spec.tsの内容を以下に差し替えて実行してみてください。

■tests/android.spec.tsファイル

// android.spec.ts
import { test, _android, expect } from '@playwright/test';

async function takeScreenshot(device) {
  const date = new Date();
  const formattedDate = `${date.getFullYear()}${('0' + (date.getMonth() + 1)).slice(-2)}${('0' + date.getDate()).slice(-2)}-${('0' + date.getHours()).slice(-2)}-${('0' + date.getMinutes()).slice(-2)}-${('0' + date.getSeconds()).slice(-2)}-${('00' + date.getMilliseconds()).slice(-3)}`;

  await device.screenshot({ path: `${formattedDate}.png` });
}

test('タイマーを5秒でセットし、発火したら止める', async () => {
  const [device] = await _android.devices();
  console.log(`Model: ${device.model()}`);
  console.log(`Serial: ${device.serial()}`);

  // タイマーを停止させる処理がなかなか動かず30秒を超えるため延長
  test.setTimeout(60000);

  {
    // 参考のため実行時間を計測
    const startTime = Date.now(); // テスト開始時刻を取得

    // 時計アプリを起動する
    await device.shell('am force-stop com.google.android.deskclock');
    await device.shell('am start com.google.android.deskclock/com.android.deskclock.DeskClock');

    // タイマータブに切り替える
    await device.tap({
      text: 'Timer'
    },);

    takeScreenshot(device);

    // 前回の状態によってはタイマーを削除する
    try {
      await device.tap({
        res: 'com.google.android.deskclock:id/left_button',
        desc: 'Delete'
        },);
    } catch (error) {
      // 失敗する場合は初期画面からの起動なので問題なし
    }

    // タイマーに設定されている時間を取得する
    const timer_setup_time_promise = await device.info({
      res: 'com.google.android.deskclock:id/timer_setup_time'
    });
    // 初期値であることを確認する
    expect(timer_setup_time_promise.text).toContain('00h 00m 00s');

    takeScreenshot(device);

    // タイマーを5秒に設定する
    await device.tap({
      res: 'com.google.android.deskclock:id/timer_setup_digit_5'
    },);

    takeScreenshot(device);

    // スタートボタンをタップする
    await device.tap({
      res: 'com.google.android.deskclock:id/fab',
      desc: 'Start'
    },);

    // 3秒間待機
    await new Promise(resolve => setTimeout(resolve, 3000));

    takeScreenshot(device);

    // 3秒間待機
    await new Promise(resolve => setTimeout(resolve, 3000));

    takeScreenshot(device);

    // アラーム鳴動までの時間を計測
    const endTime = Date.now();
    const elapsedSec = (endTime - startTime) / 1000;
  
    // 経過時間をログに出力
    console.log(`Elapsed time: ${elapsedSec} sec`);

    // ストップボタンをタップする
    await device.tap({
      res: 'com.google.android.deskclock:id/fab',
      desc: 'Stop'
    },);

    takeScreenshot(device);

    const timer_time_text_promise = await device.info({
      res: 'com.google.android.deskclock:id/timer_time_text'
    });
    expect(timer_time_text_promise.text).toContain('5');
  }

  await device.close();
});

■実行結果

PS C:\work\playwright_android> npx playwright test

Running 1 test using 1 worker

  ✓  1 tests\android.spec.ts:10:5 › タイマーを5秒でセットし、発火したら止める (33.6s)
Model: sdk_gphone_x86_64_arm64
Serial: emulator-5554
Elapsed time: 12.022 sec

  Slow test file: tests\android.spec.ts (33.6s)
  Consider splitting slow test files to speed up parallel execution
  1 passed (34.0s)
PS C:\work\playwright_android> 

■スクリーンショット

おわりに

今回実際にスクリプトを組んで動作を見てみましたが、若干動きが怪しい個所もあるので今後の改善が期待されるものの、概ね支障が無い程度には動いているのでテスト対象となるアプリ次第では結構使えるのでは無いかと思います。

AppiumのようにXPathを使ってオブジェクトを指定する、ということはできないためその点操作できないオブジェクトが出てくる可能性もありますが、そこはアプリの作りの問題(指定できるようアプリ側を改修すれば良い)だと思うので、工夫次第で如何様にもなるでしょう。

まだ正式対応という状況では無いのでPlaywrightを使ってAndroidのネイティブアプリを操作したい!という人は少ないと思いますが、興味を持って頂けた方はこの記事を参考にチャレンジしてみては如何でしょうか。

それではまた次の記事でお会いしましょう!「スキ」と「フォロー」もお願いします!


執筆者プロフィール:片山 嘉誉 (Katayama Yoshinori)
SIerとして長年AOSP(Android Open Source Project)をベースとしたAndroidスマートフォンの開発に携わり、併せてスマートフォン向けのアプリ開発を行ってきた。
Wi-FiやBluetoothといった無線通信機能の担当が長かったため通信系のシステムに強く、AWSを使用したシステム開発の経験もある。
生産性向上や自動化というワードが大好きで、SHIFTでは自動化アーキテクトとして参画。
最近のマイブームはChatGPTとStable Diffusionである。

お問合せはお気軽に
https://service.shiftinc.jp/contact/

SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/

SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/

SHIFTの導入事例
https://service.shiftinc.jp/case/

お役立ち資料はこちら
https://service.shiftinc.jp/resources/

SHIFTの採用情報はこちら

PHOTO:UnsplashMarkus Spiske


みんなにも読んでほしいですか?

オススメした記事はフォロワーのタイムラインに表示されます!