Mobile Factory Tech Blog

技術好きな方へ!モバイルファクトリーのエンジニアたちが楽しい技術話をお届けします!

Androidアプリの動作確認をAWS Device Farmで自動化してみた

この記事はモバイルファクトリー Advent Calendar 2020 23日目の記事です。


こんにちは、id:nesh です。

はじめに

今回の記事は2年前の記事と関連して、モバイルアプリのテストを自動化する話です。過去の記事 AppiumでAndroidアプリの自動テストをPerlで書いてみた - Mobile Factory Tech Blog では、Perl + Appium を使ったAndroidアプリのテストについて書きました。

今回の記事には、Appium + AWS Device Farm + Jenkins を使い、Androidのモバイルアプリの動作確認を複数端末における自動化について書きます。

背景

運用中サービスのアプリに変更を入れる場合、当サービスがサポートする端末でアプリの動作確認をするのが理想的だと思います。 コロナ禍前は、会社に検証用の端末があるため、サポートする端末や問題がありそうな端末を複数台確保して、動作確認を行いやすかったです。 しかし、2月からフルリモートで働くようになってから、手元に検証用の端末は(複数台)ない状態になっています。

手軽に複数端末で、モバイルアプリのテストをしたいので、今回目をつけたのは AWS Device Farm です。

AWS Device Farm

Device Farm は、実際に Amazon Web Services (AWS) によりホストされている電話やタブレットで、Android や iOS、およびウェブアプリを物理的にテストしてやり取りできるアプリテストサービスです。(AWS公式サイトより引用)

モバイルアプリの開発における様々な端末での動作確認を楽にしてくれるAWSのサービスです。 このサービスの使い方は2つあります。

  1. 自動アプリテスト
  2. リモートアクセスの操作

今回は様々の端末での動作確認を自動化したいので、自動アプリテストを使います。

自動アプリテスト

事前準備

Testing mobile apps across hundreds of real devices with Appium, Node.js, and AWS Device Farm | Front-End Web & Mobile

まずやっておくことは、AWS Device Farm上の準備ですが、上記のブログ記事を参考に作業します。

  1. テストするアプリを用意
  2. 動作確認をAppium (Node.js) のテストで実装
  3. AWS Device Farm上で設定し、自動テストアプリを実行

テストするアプリを用意

テストするアプリはAWS Device FarmやAppiumが用意してくれたサンプルアプリを使うのもできますが、今回は自分で作ったHelloWorldを表示するアプリを使います。

GitHub - fadlil/HelloWorld

app/outputs
└── app-debug.apk

動作確認をAppium (Node.js) のテストで実装

テストしたいことは、アプリを起動できるかどうかだけにします。

// テストフレームワーク
var expect = require('chai').expect;

// node.js でappiumを使う
var wd = require('wd');
var driver = wd.promiseChainRemote({
    host: 'localhost',
    port: 4723
});

var assert = require('assert');
describe('AWSDeviceFarmReferenceAppTest', function () {
    before(function () {
        this.timeout(300 * 1000);
        return driver.init();
    });

    after(function () {
        console.log("quitting");
    });
   
    // アプリが起動できて、'Hello World!!' が表示されるテスト
    it('test_app_is_loaded', async function () {
        const element = await driver.elementById("com.example.nesh.helloworld:id/change");
        expect(element).to.exist;
    });
});

このテストをそのままローカルで実行すると失敗します。 driver.init() に必要なデバイスの情報が足りないからです。 ただ、AWS Device Farm上で実行される時、これらの情報がよしなに補完されます。

このテストファイルをAWS Device Farmで使うために、 npm-bundle と zip化する必要があります。

AWS Device Farm上で設定し、自動テストアプリを実行

  1. 新しくプロジェクトを作成
  2. 新しいrunを作成して、必要な項目を設定
    • アプリの *.apk ファイルをアップロード
    • zip化されたテストファイルをアップロード
    • デバイスを選択
  3. 必要設定を埋めたら、自動アプリテストを実行

必要な作業は大体上記の通りです。

ここまでの作業で、モバイルアプリを複数端末で手軽に自動テストできるようになりました。 しかし、AWS Device Farm上の操作自体が手間になると思います。 この手間を無くし、継続的にテストを回せたいと思っているので、Jenkins で自動化することにしました。

Jenkinsで自動化

自動化するのは、 AWS Device Farm上で設定し、自動テストアプリを実行 の操作です。 操作自体は単純で手間ではないのですが、自動にできる部分は自動化したい気持ちです。 自動化するといっても、JenkinsにAWS Device Farm用のプラグインが用意されてるので、簡単に自動化できます。

AWS Device Farm の Jenkins CI プラグインとの統合 - AWS Device Farm

Jenkinsで使うプラグインは aws-device-farm | Jenkins plugin です。 この記事は上記に用意したサンプルリポジトリを使った場合、設定のスクリーンショットをいくつか貼ります。

f:id:nesh:20201222101827p:plain:w500
Jenkinsのプロジェクトの設定1

ProjectDevice Pool はAWS Device Farm上に設定されてるものを参照します。Application はサンプルリポジトリ上のアプリファイルのパス

f:id:nesh:20201222101833p:plain:w500
Jenkinsのプロジェクトの設定2

今回のテストは Appium (Node.js) を使うので、該当テストファイルのパスを入力します。

f:id:nesh:20201222102115p:plain:w500
Jenkinsのプロジェクトの設定3

テスト環境の設定は、AWS Device Farm上に手動で自動アプリテストを実行した時のものをそのまま使います。

f:id:nesh:20201222103034p:plain:w400
Jenkinsでの自動化が成功

これで、Jenkinsでの自動化のための設定ができて、実行して成功できました。

試してみた所感

  • AWS Device Farm の自動アプリテストはアプリ開発時の動作確認に便利
    • 最初の設定も簡単で、テストするアプリさえあればすぐにできる
    • テストデバイスの起動時間が合計1,000分まで無料なので、気楽に試せる
  • アプリ開発時のサポート端末での起動確認などで使えそう
  • 自動化に関しては、アプリのビルドやテストファイルの npm-bundle + zip などの作業も自動化すれば理想的

日々の面倒な作業を自動化して、快適な開発ライブを充実しましょう。


明日の記事は id:kfly8 さんです!

GoでDocBaseAPIのCLIクライアントを作ってみよう

この記事はモバイルファクトリー Advent Calendar 2020 22日目の記事です。

こんにちは。エンジニアの id:Eadaeda です。普段はサーバーサイドの面倒を見ています。

DocBase

弊社ではドキュメント共有ツールとしてDocBaseを利用しています。Markdown形式で書いた文章を投稿することが出来、記事の埋め込みや検索なども便利です。私もチケットのワークログや議事録、シェアナレ!で書いたものなどを投稿しています。

docbase.io

ブラウザ内のエディタでDocBaseの記事(DocBaseではメモとよんでいます)を編集する場合は、エディターへ画像や動画などのファイルをドラッグアンドドロップすることでアップロードすることが出来ます。その時、以下のように自動でMarkdownを挿入してくれます。

画像なら
![ファイル名](URL)

動画などならリンク
[動画](URL)

なので、ブラウザ内のエディタを使っている場合、ファイルを挿入したい位置にカーソルをあわせ、ドラッグアンドドロップでアップロードするだけで良いのです。特段珍しい機能とは思いませんが、嬉しい機能ですよね。

ところで、私は何らかの文章を書くとき、EmacsやVim、nanoといったコマンドライン上で動作するテキストエディタを使っていて、この記事もそこで書いています。ちなみにどのエディタを使っているかはナイショです。

こういったエディタを使うのは、出来るだけターミナルから動きたくないからなのですが、そこでメモを書いていると困ってしまうことが1つあります。メモに貼り付けたい画像などのURLが先にわからないことです。

![image](ここがわからない)

これはなかなか大変なことです。書いているときは、例えば画像を挿入したい位置に __ここに画像__ などのマーカーを置いておき、あとからブラウザ内エディタにコピペ、マーカーを探して順番通りに画像をドラッグアンドドロップしていかなければなりません。挿入したいファイルが現れるたびにアップロードし、URLを取得する方法もありますが、先に述べたように私は出来るだけターミナルから動きたくありませんでした。

これをなんとかしたい…というよりはなんとかしなければならなかったので、なにか使えるものは無いかと探していました。するとDocBaseが公開しているAPIを見つけました。

help.docbase.io

このAPIはレスポンスとして以下のようなJSONを返すようです。

[
    {
        "created_at": "[ここは投稿した時間]",
        "id": "[ここはID]",
        "markdown": "![example.png]([ここは画像へのURL])",
        "name": "example.png",
        "size": 285,
        "url": "[ここも画像へのURL]"
    }
]

嬉しいことにMarkdownへ埋め込みが返されます。これをメモに貼り付ければ良いので、一度もターミナルから離れずにメモを完成させられそうですね。

CLIツールにする

さて、毎回curlコマンドを叩くのも良いのですが、いささか書き換えが面倒です。アップロードしたいファイルへのパスを与えるだけでアップロードまで行ってくれるシェル関数を書くなどをするのも有りでしょう。

でも今回は、Goで専用のCLIツールを作ってみようと思います。ここでようやく本題です。前置きが長くて申し訳ない。ちなみになぜGoでCLIツールを作るのかと言うと、単純にGoでCLIを作るのが好きなのと社内でGoを使ったCLIツールを作る人が増えてほしいからです。

GoにはCLIフレームワークとして公開されているものがたくさん有りますが、今回はcobraを使います。この間公開されたGitHubのCLIツールにも使われていますね。私もよく使いますが、後に紹介するviperとの連携が強力で好きです。

github.com github.com

まずはどんなものを作ったのか、どんなものが作れるのかを理解してもらいたいので、ヘルプの出力とuploadサブコマンドでexample.pngをアップロードしたときの様子を以下のGIFに示します。

f:id:Eadaeda:20201221185118g:plain

このような感じですね。非常にシンプルです。

実装

さて、早速私が実装したものを見てもらいます。

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "io/ioutil"
    "net/http"
    "os"
    "path/filepath"

    "github.com/spf13/cobra"
    "github.com/spf13/viper"
)

// docbaseコマンドの本体
var rootCmd = &cobra.Command{
    Version: "0.0.1",
    PreRunE: func(cmd *cobra.Command, args []string) error {
        // API Tokenとチーム名は必須なので、どちらかが空な場合はアプリを終了する
        if len(viper.GetString("token")) == 0 {
            return errors.New("DocBase API Tokenが空です")
        }
        if len(viper.GetString("team")) == 0 {
            return errors.New("チーム名が空です")
        }
        return nil
    },
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Println("これはサブコマンドを指定しなかったときに実行されるコードだよ")
    },
}

// uploadサブコマンド
// docbase uplaod という感じに呼び出せる
var uplaodCmd = &cobra.Command{
    Use: "upload",
    Run: func(cmd *cobra.Command, args []string) {
        err := upload(args[0])
        if err != nil {
            fmt.Fprintln(os.Stderr, err)
            os.Exit(1)
        }
    },
}

func upload(path string) error {
    filename := filepath.Base(path)
    fp, err := os.Open(path)
    if err != nil {
        return err
    }
    defer fp.Close()

    content, err := ioutil.ReadAll(fp)
    if err != nil {
        return err
    }

    // viperからAPIトークンとチーム名の値をもらう
    token := viper.GetString("token")
    team := viper.GetString("team")

    // リクエストボディを作る
    // [
    // {"name": filename, "content": content}
    // ]
    data, err := json.Marshal([]struct {
        Name    string `json:"name"`
        Content []byte `json:"content"`
    }{
        {Name: filename, Content: content},
    })
    if err != nil {
        return err
    }
    body := bytes.NewBuffer(data)

    // http.Request{}を作る
    r, err := http.NewRequestWithContext(
        context.Background(),
        "POST",
        fmt.Sprintf("https://api.docbase.io/teams/%s/attachments", team),
        body)

    // ヘッダーに環境変数から拾ったアクセストークンをセット。これがないと弾かれる
    r.Header.Set("X-DocBaseToken", token)
    r.Header.Set("Content-Type", "application/json")

    // ここから実際にリクエストを投げる処理
    client := &http.Client{}
    res, err := client.Do(r)
    if err != nil {
        return err
    }
    defer res.Body.Close()

    // レスポンスを読んで、Stdoutに出力
    resBody, err := ioutil.ReadAll(res.Body)
    if err != nil {
        return err
    }

    // Jsonの出力
    var b []byte
    resJson := bytes.NewBuffer(b)
    json.Indent(resJson, resBody, "", "  ")
    fmt.Println(resJson.String())

    return nil
}

func init() {
    // オプションの追加
    // PersistentFlags に追加すると、追加されたcobra.Commandに追加したサブコマンドでも使えるオプションになる
    // そうしたくない場合は Flags に追加すれば良い
    // --tokenオプション、文字列を受け付ける。デフォルト値は ""、最後の引数は --helpで出力されるそのオプションの説明
    rootCmd.PersistentFlags().String("token", "", "DocBase API Token")
    // こっちは--teamオプション。
    rootCmd.PersistentFlags().String("team", "", "Team name")

    // viperで値を管理
    // "token" に、`token`という名前のオプションの値をバインドする
    viper.BindPFlag("token", rootCmd.PersistentFlags().Lookup("token"))
    // "token" に、`DOCBASE_TOKEN` という環境変数の値をバインドする
    viper.BindEnv("token", "DOCBASE_TOKEN")

    viper.BindPFlag("team", rootCmd.PersistentFlags().Lookup("team"))
    viper.BindEnv("team", "DOCBASE_TEAM_NAME")

    // uploadサブコマンドを追加する。
    rootCmd.AddCommand(uplaodCmd)
}

func main() {
    if err := rootCmd.Execute(); err != nil {
        os.Exit(1)
    }
}

かなり愚直な実装になりましたが、とりあえずできました。ではこれを使って実際にファイルをアップロードしてみましょう

$ export DOCBASE_TOKEN="ここにあなたのアクセストークン"
$ export DOCBASE_TEAM_NAME="ここにあなたのチーム名"

$ go run main.go upload ./example.png

これで、以下のようなレスポンスが返ってきました。

[
  {
    "id": "アップロードしたファイルのID",
    "name": "example.png",
    "size": 285,
    "url": "https://image.docbase.io/uploads/アップロードしたファイルのID",
    "markdown": "![example.png](画像へのURL)",
    "created_at": "2020-12-08T17:17:09+09:00"
  }
]

アップロード出来ていそうですね。ここから"markdown"の値を取り出し、メモにを貼り付けるだけです。

あとは、go buildgo installでバイナリを得れば良いですね。

$ go build -o docbase
$ mv ./docbase "$PATHの通っているところ"

$ go install

オプション

今回の実装では、cobraviperを組み合わせて、APIトークンとチーム名をオプションで切り替えられるようにしました。ちょうど実装の以下の部分ですね。

func init() {
    // オプションの追加
    // PersistentFlags に追加すると、追加されたcobra.Commandに追加したサブコマンドでも使えるオプションになる
    // そうしたくない場合は Flags に追加すれば良い
    // --tokenオプション、文字列を受け付ける。デフォルト値は ""、最後の引数は --helpで出力されるそのオプションの説明
    rootCmd.PersistentFlags().String("token", "", "DocBase API Token")
    // こっちは--teamオプション。
    rootCmd.PersistentFlags().String("team", "", "Team name")

    // viperで値を管理
    // "token" に、`token`という名前のオプションの値をバインドする
    viper.BindPFlag("token", rootCmd.PersistentFlags().Lookup("token"))
    // "token" に、`DOCBASE_TOKEN` という環境変数の値をバインドする
    viper.BindEnv("token", "DOCBASE_TOKEN")

    viper.BindPFlag("team", rootCmd.PersistentFlags().Lookup("team"))
    viper.BindEnv("team", "DOCBASE_TEAM_NAME")

    // uploadサブコマンドを追加する。
    rootCmd.AddCommand(uplaodCmd)
}

オプションが指定されればその値を、されなければ環境変数を使う。というような感じで viperが値をよしなに決定してくれます。これにより、以下のような使い方で、APIトークンとチーム名を切り替えられるようになりました。

# オプションなし。環境変数を読みに行く
$ docbase

# --tokenに値を渡す
# APIトークンは `hoge` として処理がすすむ。チーム名は環境変数の値のまま
$ docbase --token=hoge

# --teamも渡してみる
# APIトークンは `hoge`、チーム名は `fuga` として処理される
$ docbase --token=hoge --team=fuga

cobra, viperは非常に強力で他にもまだまだできることがあるので、是非READMEなどを読んでみてほしいです。

まとめ

今回は「ターミナルから動かずにDocBaseにファイルをアップロードしたい!」という願いを叶えるため、Goを使って、DocBaseにファイルをアップロードするCLIツールを作りました。フレームワークとしては cobraviperを選択し、この後の機能拡張も考えて「アップロード処理を行うuploadサブコマンド」として実装しました。これをきっかけに社内でGoが大流行すればいいなあと思っています。

また、本当にターミナルから一歩も離れないことを目指すのであれば、メモの更新・作成を行うサブコマンドを実装する必要がありますが、投稿する前にプレビューなども見たいので、そこは手動にすることとしました。他のAPIは今後ほしいという要望が(私から)出れば実装すると思います。

明日の記事は id:nesh さんです!楽しみですね!