DeNA Testing Blog

Make Testing Fun, Smart, and Delighting End-Users

WebdriverIOでAppiumを使う勘所

こんにちは。SWETの外山(@sumio_tym)です。 SWETの一員になって約9か月、Appiumを使ってAndroidアプリのUIテスト自動化を行いつつ、 DroidKaigi 2018に登壇したり、 書籍「Androidテスト全書」を執筆したりしていました1

先日(2018年9月28日)に電子版が正式リリースされた「Androidテスト全書」の宣伝をしたいのもやまやまなのですが、 今回はAppiumを使ったAndroidのUIテスト自動化業務で得られたノウハウを紹介します。

Appiumを使ったUIテスト自動化経験があっても、使っているプログラミング言語は組織やプロジェクトによって様々だと思います。 本エントリーでは、Appiumの経験はあるもののJavaScriptでテストを書くのは初めての方に向けて、 クライアントライブラリにWebdriverIOを採用するときのノウハウを次の3点に分けて説明します。

  • セットアップするときのポイント
  • テストコードを書くときのポイント
  • テスト失敗時に解析しやすくする工夫

Appium経験者に向けたエントリーであるため、Appiumの詳しい解説はしていません。 Appiumについて詳しく知りたい方は公式ドキュメントを参照してください。

また、ノウハウの一部はAndroidでしか通用しないものもありますのでご注意ください。

WebdriverIOとは

Appium向けのクライアントライブラリのうち、JavaScriptに対応しているものは次の2つです。 どちらもJavaScript(Node.js)によるWebDriver実装です。

今回取り上げるWebdriverIOで特徴的なものは次の2点です。

  • 同梱されているCLIベースのテストランナーwdio
  • WebdriverIOサービスプラグインwdio-appium-service

wdioを使うと、async/awaitを使わずともテストコードを同期的に書くことができます。 JavaScriptに付きものの非同期処理から解放されてテストコードを書けるのは、思いの外便利です。

wdio-appium-serviceはWebdriverIOとAppiumを協調動作させるためのプラグインです。 このプラグインを使えばテスト実行・終了と合わせて、自動的にAppiumを起動・終了してくれます。

以降で説明するようにセットアップすれば、両者の機能をフル活用してテストを書き始めることができます。

なお、今回は次のバージョンで動作を確認しました。

  • Node.js: 10.9.0
  • WebdriverIO: 4.3.12
  • Appium: 1.9.0
  • wdio-appium-service: 0.2.3

セットアップするときのポイント

インストールと設定ファイルの雛形作成

Node.jsは既にインストールされているものとします。 テストコードを書くための空ディレクトリを作成し、 以下のようにwebdriverioパッケージをインストールします。

$ npm init
$ npm install --save-dev webdriverio

次に、以下のコマンドを実行します。 このコマンドは対話形式でWebdriverIOの設定ファイルを作成するものです。

$ npx wdio config

いくつか質問されますが、以下の質問以外は全てデフォルトのままEnterを押して先に進めてください。

  • Which reporter do you want to use?: テスト結果レポートをどのように表示するか選択します。
    こだわりがなければspecjunitを選択しておくのが無難です。
  • Do you want to add a service to your test setup?: appiumを選択します。 ここでappiumを選択すると、テスト実行の度にAppiumが自動的に起動するようになります。
  • Level of logging verbosity: 原因解析に有用ですのでverboseにするのがお勧めです。

全ての質問に回答するとWebdriverIOの設定ファイルwdio.conf.jsの雛形が生成されます。 あわせて、関連パッケージが自動的にインストールされます。

最後にAppium本体とアサーションライブラリ(ここではchai)をインストールします。

$ npm i --save-dev appium chai

wdio.conf.jsファイルの編集

生成された設定ファイルwdio.conf.jsは、そのままではAppiumを使ったテストを書くことができません。 少なくとも以下の修正が必要です。

同時起動数を1にする

WebdriverIOにはテストをできるだけ並行実行するという特徴がありますが、 そのときにAppiumも同じ待ち受けポートで複数個起動してしまい、うまく動作しません。

その事象を回避するため、maxInstances1にしてください。

exports.config = {
  ...
  maxInstances: 1,
  ...
}

Desired Capabilitiesを適切に設定する

設定ファイルの雛形にはFirefoxを利用するDesired Capabilitiesが設定されています。 この部分をAppiumで利用するDesired Capabilitiesに置き換えてください。 たとえば以下のようになります。

exports.config = {
  ...
  capabilities: [{
    platformName: 'Android',
    automationName: 'UiAutomator2',
    app: './app-release.apk',
    deviceName: 'Android Device',
    unicodeKeyboard: true
  }],
  ...
}

capabilitiesにはDesired Capabilitiesの配列を指定するようになっていますが、 配列の要素は1つだけにしてください。 複数の要素を持たせると、書いた要素の数だけAppiumが同時に起動してしまいます。

また、雛形にはcapabilitiesの中にもmaxInstancesが設定されていますが、 こちらも同じ理由で削除しなければなりません。

接続先情報を設定する

Appiumサーバの待ち受けポートのデフォルト値は4723ですが、 WebdriverIOの接続先ポート番号のデフォルト値は異なります。そのため、以下のとおり接続先を明示的に指定してください。

exports.config = {
  ...
  host: 'localhost',
  port: 4723,
  ...
}  

npm testでテストを実行できるようにする

最後に、package.jsonを以下のように変更します。

{
  ...
  "scripts": {
    "test": "wdio",
    ...
  },
  ...
}

これでnpm testコマンドを使ってテストを実行できるようになりました。

テストコードを書くときのポイント

次に、テストコードを書くときに、Appiumだからこそはまるポイントを説明します。 WebdriverIOの詳しい使い方は公式ドキュメントに譲りますが、 次の点を押さえておくと、これから説明する内容が理解しやすくなると思います。

  • テストコードからは、グローバル変数browserにアクセスできます
  • browserが提供するメソッドの一覧はWebdriverIO APIで確認できます
  • browserが提供するメソッドは非同期ですが、前述のwdioテストランナーから呼び出されるときはawaitなしでも同期呼び出しになります
  • UIコンポーネントの操作はbrowser.click(selectorText)のように行います

UIコンポーネントの特定

簡略化された書き方 でUIコンポーネントを探せるのがWebdriverIOの特徴のひとつになっています。 しかし、この書き方はブラウザを操作するときであれば威力を発揮するものの、モバイルアプリではそうはいきません。

特に「テキスト」の判定は、ブラウザを前提とした公式ドキュメントの説明に惑わされてはいけません。 ブラウザで「テキスト」と言うと、<span>この部分</span>を指しますが、 Androidでは<android.widget.Button text="この部分" />になるため、WebdriverIOの簡略記法は使えないのです。

たとえばAndroidの場合は、次のようなユーティリティメソッドを用意しておくと良いでしょう。

module.exports = {
  /** リソースIDを指定するセレクタ */
  resourceId(id) {
    return `//*[@resource-id="${id}"]`;
  },

  /** 表示テキストを指定するセレクタ */
  text(text) {
    return `//*[@text='${text}']`;
  },

  /** contentDescription属性を指定するセレクタ */
  accessibilityId(text) {
    return `~${text}`;
  },

  /** UIコンポーネントのクラス名(android.widget.Buttonなど)を指定するセレクタ */
  className(className) {
    return `android=new UiSelector().className("${className}")`;
  }
};  

このユーティリティメソッドを使えば、 「OKと表示されたUIコンポーネントをクリックする」という操作は次のように書けます。

const by = require(...);
browser.click(by.text('OK'));

スクロール

WebdriverIOには scroll(selectorText)メソッドが存在しており、 ドキュメントには、あるコンポーネントが画面内に入るまでスクロールできると書かれています。

しかし、このメソッドはAppiumでは動作しませんので、自身で実装が必要です。 たとえば、下方向にスクロールする実装は次のようになります。

  scrollDown(selectorText) {
    const screenSize = browser.windowHandleSize().value;
    const width = screenSize.width;
    const height = screenSize.height;
    const isOnScreen = () => browser.isExisting(selectorText) && browser.isVisible(selectorText);
    browser.waitUntil(() => {
      // 既に画面内に有る場合は、スクロールせずに脱出する
      if (isOnScreen()) {
        return true;
      }

      // 画面中央をタップし、少し200pxだけy方向に動かす。
      browser.touchAction([
        {
          action: 'press',
          x: width / 2,
          y: height / 2
        },
        // Appium1.8からはこのwaitが必要
        {
          action: 'wait',
          ms: 500
        },
        {
          action: 'moveTo',
          x: width / 2,
          y: height / 2 + 200
        },
        'release'
      ]);

      return isOnScreen();
    }, 180000);
  }

このサンプルコードでは、最初に端末の画面サイズを取得しています。 その後、目的のUIコンポーネントが画面内に見つかるかタイムアウト(ここでは180秒)するまで、以下の処理を繰り返しています。

  • pressで画面中央をタップします
  • waitで500msec待ちます。この処理はAppium 1.8より必要になりました
  • moveToで、画面中央から200pxだけ下方向に指を動かします
  • releaseで指をリリースします

テスト失敗時に解析しやすくする工夫

テストに失敗したときには、次の情報があると解析が捗ります。

  • Appiumのログ
  • テストに失敗したときのスクリーンショット
  • テストを実行した端末のログ

このうちAppiumのログは、ログレベルをverboseにしておけば標準出力に表示されます。 残りの2つについてAndroidの場合の設定を紹介します。

スクリーンショットを保存する

WebdriverIOはデフォルトで、コマンド(browserが提供している各メソッド)実行に失敗したタイミングでスクリーンショットを保存してくれます2。 ところが、画面に表示されている文字列が想定と異なる場合などではスクリーンショットが保存されません。 そのようなケースではassertには失敗しているものの、コマンドの実行は成功しているからです。

この問題を解決するため、テスト失敗時(assertに失敗したとき)にもスクリーンショットを保存するようにします。 テストが終了する度にwdio.conf.jsafterTest関数(デフォルトではコメントアウトされています)がコールバックされるため、その中にスクリーンショットを保存する処理を書いていきます。

exports.config = {
  ...
  /**
   * Function to be executed after a test (in Mocha/Jasmine) or a step (in Cucumber) ends.
   * @param {Object} test test details
   */
  afterTest: async function(test) {
    // エラーでないときは何もせずにreturnする
    if (!test.err) {
      return;
    }

    const fs = require('fs');
    const dateFormat = require('dateformat');
    // ファイル名を現在時刻(秒)で生成するため、重複しないように1秒スリープする
    await browser.pause(1000);
    const dateString = dateFormat(Date.now(), 'isoDateTime');

    // スクリーンショットの保存
    const screenshotFileName = `screenshot-${dateString}.png`;
    await browser.saveScreenshot(`./screenshots/${screenshotFileName}`);
  },
  ...
}  

このサンプルコードでは./screenshots/ディレクトリにscreenshot-2018-10-02T21:58:29+0900.pngのようなファイル名でスクリーンショットを保存しています。 ファイル名に現在時刻を含めるためにdateformatを利用していますので、 あらかじめnpm installコマンドでdateformatをインストールしておいてください。

スクリーンショットを取得しているのはbrowser.saveScreenshot()です。 afterTest関数の中ではbrowserが提供しているAPI呼び出しであっても非同期呼び出しになるため、 明示的にawaitキーワードを付けて処理の終了を待たなければならない点に注意してください。

logcatを保存する

WebdriverIOではbrowser.log()の引数に‘logcat’を指定することで、logcatを取得できます。 スクリーンショットを保存したときと同じく、wdio.conf.jsafterTest関数に処理を追加していきましょう。

前述のスクリーンショット保存コードに続けて、次のコードを追加してください。

    // logcatの保存
    const logcatFileName = `logcat-${dateString}.log`;
    const logcats = await browser.log('logcat');
    logcats.value.map(o => {
      const timestamp = dateFormat(new Date(o.timestamp), 'yyyy-mm-dd HH:MM:ss o');
      const message = o.message;
      return `${timestamp} - ${message}\n`;
    }).forEach(logline => {
      fs.appendFileSync(`./logs/${logcatFileName}`, logline);
    });

browser.logcat()はlogcatの各行(timestampmessageで構成)を要素とした配列を返します。 このサンプルコードでは、各行をyyyy-mm-dd HH:MM:ss +0900 - ログメッセージという形に加工して上で ./logsディレクトリにlogcat-2018-10-02T21:58:29+0900.logのようなファイル名で保存しています。

afterTest関数の全体像

前述の2つの処理を書いたafterTest関数の全体像は次の通りです。 コピーして試してみる場合は、./logsディレクトリと./screenshotsディレクトリを事前に作成しておいてください。

exports.config = {
  ...
  /**
   * Function to be executed after a test (in Mocha/Jasmine) or a step (in Cucumber) ends.
   * @param {Object} test test details
   */
    // エラーでないときは何もせずにreturnする
    if (!test.err) {
      return;
    }
    const fs = require('fs');
    const dateFormat = require('dateformat');
    // ファイル名を現在時刻(秒)で生成するため、重複しないように1秒スリープする
    await browser.pause(1000);
    const dateString = dateFormat(Date.now(), 'isoDateTime');

    // スクリーンショットの保存
    const screenshotFileName = `screenshot-${dateString}.png`;
    await browser.saveScreenshot(`./screenshots/${screenshotFileName}`);

    // logcatの保存
    const logcatFileName = `logcat-${dateString}.log`;
    const logcats = await browser.log('logcat');
    logcats.value.map(o => {
      const timestamp = dateFormat(new Date(o.timestamp), 'yyyy-mm-dd HH:MM:ss o');
      const message = o.message;
      return `${timestamp} - ${message}\n`;
    }).forEach(logline => {
      fs.appendFileSync(`./logs/${logcatFileName}`, logline);
    });
  },
  ...
}  

まとめ

WebdriverIOでAppiumを使ったテストを書くときのノウハウを紹介しました。 WebdriverIOの公式ドキュメントにはAppiumの場合の説明が少ないのですが、 本エントリで紹介した内容を押さえておけば、基本的なテストは書き始められると思います。 参考にしてみてください。


  1. 大変ありがたいことに、業務時間の一部を執筆活動に充てることをリーダーの@okitanさんに快諾していただきました。このような柔軟性がSWETグループの魅力のひとつです!

  2. 保存先はwdio.conf.jsscreenshotPathで指定したディレクトリ(デフォルト値は'./errorShots/')です。