UIテストと録画の関係、実装方法

この記事は [リクルートライフスタイル Advent Calendar 2017](https://qiita.com/advent-calendar/2017/recruitlifestyle) 20日目の記事です。

# はじめに
一昨日、初めての子供が生まれた @AHA_oretama です。
いろいろプライペートで忙しく、ちょっと投稿が遅れてしまいましたが、許してください！

普段はリクルートでR-SETという活動をしています。
SETという言葉自体はGoogleが提唱しているロールで[Google Testing Blog](https://testing.googleblog.com/2011/02/how-google-tests-software-part-two.html)で紹介されています。SETの説明部分の抜粋が以下になります。

```
The SET or Software Engineer in Test is also a developer role except their focus is on testability.
```

要約すると、`テスタビリティに着目したエンジニア → SET`となります。

R-SETとは検索性をあげるために作った造語で以下のように定義しています。
```
R-SET is Recruit Lifestyle's SET.
```

R-SETでは、リクルートライフスタイルのサービスのテスタビリティを上げることで

* サービスの開発生産性の向上
* サービスの品質の向上

をミッションとして掲げています。

さて、紹介も済んだところで、今回はアプリのUIテストの録画についてお話したいと思います。

# UIテストの特性と録画機能について

まずここで言っているUIテストの認識を揃えておきます。

<img width="350" alt="スクリーンショット 2017-12-20 18.57.53.png" src="https://qiita-image-store.s3.amazonaws.com/0/110216/832f90f9-1a63-c0fe-0d82-b6d916c17ab5.png">

テストピラミッドという言葉をご存知でしょうか？？
テストピラミッドは自動化するテストの特性を表した図で、上から順に、UIテスト、Integrationテスト、Unitテストが並んでいます。
一般的にUIテストは全てのモジュール、外部サービスを繋いだ状態でのEnd-to-Endテストのことを指すことが多いです。

今回はこのテストピラミッドにあわせたものをUIテストと呼んでいます。

ピラミッドの特性として、ピラミッドの大きさが自動化のテストケース量の理想的な比率を表します。
つまり、Unitテストを多く作り、UIテストは多く作りすぎないのが理想と言われています。
またピラミッドの上へいけば行くほど、不安定でテスト実行時間がかかる、またコストもかかる、という特性もあります。

そして、このUIテストの特性が録画の必要性につながります。

自動化にはいくつものメリットがありますが、その中の一つにコストの削減があります。
ただし、UIテストはその特性上、不安定なテストになってしまいがちです。
UIテストを実施したことがある人であれば、ソースを変更していないのにも関わらず、あるときはテストが成功し、あるときはテストが失敗する、みたいなことを経験したことがあるのではないでしょうか？
テストが失敗すると、そのときにはテストの失敗原因を特定する必要が出てきますが、不安定なテストなので、失敗したテストを再度実行したとしても再現するとは限りません。
何度も何度も実行しないと、同じような状態が再現できず、原因調査に時間がかかってしまいます。
また、UIテストにはテストの実行時間がかかるという特性もあります。
失敗したテストがテストシナリオの終盤であればあるほど、その状態まで実施するのに時間がかかってしまうことになります。

みなさんお気づきでしょうか？
自動化のメリットがコスト削減であるにも関わらず、自動化したテストのメンテンナンスにコストがかかり、メリットが失われてしまっていることに！

では、このときに失敗したテストが録画されていたらどうでしょうか？

例えば

* 画面の描画に時間がかかり、描画できていなかった
* 入力する項目が入力できていなかった
* テストケースの前半で正常に処理ができず、後続処理で落ちた
* 画面のアニメーションが入力やクリック処理を妨げていた

など原因が分かるようになることも多くなるはずです。

つまり、録画は、UIテストの不安定さ、実行時間の長さによるコスト増への対処であり、自動化のメリットを効率的に得るために必要なことになります。

# 録画の方法

UIテストで録画することの重要性がわかったところで、録画方法について紹介します。
今回はアプリに絞りますので、WEBについてはまた別の機会があれば紹介したいと思います。

## Android端末での録画

Android端末の録画は以下のADBコマンドで実行することができます。

```bash
adb shell screenrecord {filename}
```

`Ctrl+C`で停止できます。

ここで、出力されたファイルはAndroid端末上に保存されます。
filenameはAndroid端末上でのファイル名なので、`/sdcard/demo.mp4`のようになります。

Android端末上で保存されたファイルはローカルPC上に保存することが多いと思います。
以下のADBコマンドでAndroid端末上のファイルをローカルPC上に出来ます。

```bash
adb pull {remote} {local}
```

[ユーザガイド](https://developer.android.com/studio/command-line/adb.html?hl=ja)によると、録画機能はAndroid 4.4（API レベル 19）以降をサポート対象にしていますので、注意してください。
また最大録画時間は最大でも 180秒（3分）であることも注意が必要です。

## iOS Simulatorの録画

iOS Simulatorの録画は以下のXcode command-line utilityのコマンドで実行できます。

```bash
xcrun simctl io booted recordVideo {filename}
```

iOS Simulatorの場合は保存先はローカルPCになります。
iOSも`Ctrl+C`で停止することができます。

[リリースノート](https://developer.apple.com/library/content/releasenotes/DeveloperTools/RN-Xcode/Chapters/Introduction.html#//apple_ref/doc/uid/TP40001051-CH1-SW9)によると、この録画機能はXcode 8.2からの機能であるのでご注意ください。

## Javaでの実装

Appiumを使用してUIテストを作り、そのテストを録画するという機能をJavaで実装します。
AppiumではRubyの日本語ドキュメントが多い一方、他の言語の日本語ドキュメントが少ない（本家の英語サイトもですが、、、）ので、JavaでAppiumの機能を書くことには意味があると思っています。

ここでは、具体的な実装を示します。

念のため環境ですが、以下の環境で確認しています。

* Java 1.8.0_102
* Android 6.0
* Xcode 8.3.3
* [Appium](http://appium.io/) 1.7.1

それでは実装を紹介します。

```java
public class RecordFactory {

    public static RecordFactory factory = new RecordFactory();

    public static RecordFactory getInstance() {
        return factory;
    }

    public Record createRecord(AppiumDriver driver, String fileName, String output) {
        if (driver.getPlatformName().equals("Android")) {
            return new AdbRecord(fileName, output);
        } else {
            return new iOSSimulatorRecord(fileName, output);
        }
    }
}
```

```java
interface Record extends Closeable {
  void start()
}
```

上記コードの解説です。
`AppiumDriver.getPlatformName()`で実行端末（AndroidかiOSか）が取れます。
AppiumはクロスプラットフォームでのUIテストツールという強みがあるので、ここでは実行中の端末を判別し、それにあわせてクラスを作るFactoryクラスを作ることで、録画する端末によらない使い方をできるようにしています。
録画処理は`Closeable`のイメージがあったのでそのインタフェースを追加していますが、とくになくてもOKだと思います。


```java
public class AdbRecord implements Record {

    private final String fileName;
    private final String outputDir;
    private Process recordProcess;
    private final String outputPath;

    AdbRecord(String fileName,String outputDir) {
        this.fileName = fileName.endsWith(".mp4") ? fileName : fileName + ".mp4";
        this.outputDir = outputDir;
        this.outputPath = outputDir + "/" + fileName;
    }

    @Override
    public void start() throws IOException {
        ProcessBuilder builder =
            new ProcessBuilder("adb", "shell", "screenrecord", "/sdcard/" + fileName);
        builder.redirectErrorStream(true);

        recordProcess = builder.start();
    }

    @Override
    public void close() throws IOException {
        if (recordProcess != null) {
            int pid = 0;
            try {
                Field field = recordProcess.getClass().getDeclaredField("pid");
                field.setAccessible(true);
                pid = field.getInt(recordProcess);
            } catch (IllegalAccessException | NoSuchFieldException e) {
                e.printStackTrace();
            }

            while(true) {
                ProcessBuilder builder = new ProcessBuilder("kill", "-2", String.valueOf(pid));
                builder.start();
                TestUtils.sleep(1000);
                if(!recordProcess.isAlive()) {
                    break;
                }
            }
        }

        File dir = new File(outputDir);
        if(!dir.exists()) {
            dir.mkdir();
        }

        // 動画ファイルの完成までに若干のタイムラグがあるためSleepする
        TestUtils.sleep(3000);

        // Android端末上の動画ファイルをローカルにコピーする
        execProcess("adb", "pull", "/sdcard/" + fileName, outputPath);
        // Android端末上の動画ファイルを削除する
        execProcess("adb", "shell", "rm", "-f","/sdcard/" + fileName);

        try (InputStream stream = Files.newInputStream(Paths.get(outputPath))) {
            Allure.addAttachment(fileName, stream);
        }
    }

    private void execProcess(String... args) throws IOException {
        ProcessBuilder builder = new ProcessBuilder(args);
        Process process = builder.start();
        try {
            process.waitFor(20, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
```

```java
public class iOSSimulatorRecord implements Record {

    private Process recordProcess;
    private final String fileName;
    private final File outputDir;
    private final String outputPath;

    iOSSimulatorRecord(String fileName, String outputDir) {
        this.fileName = fileName.endsWith(".mov") ? fileName : fileName + ".mov";
        this.outputDir = new File(outputDir);
        this.outputPath = this.outputDir.getAbsolutePath() + "/" + fileName;
    }

    @Override
    public void start() throws IOException {
        if(!outputDir.exists()) {
            outputDir.mkdir();
        }

        ProcessBuilder builder =
            new ProcessBuilder("xcrun", "simctl", "io", "booted", "recordVideo", outputPath);
        builder.redirectErrorStream(true);

        recordProcess = builder.start();
    }

    @Override
    public void close() throws IOException {

        if (recordProcess != null) {
            int pid = 0;
            try {
                Field field = recordProcess.getClass().getDeclaredField("pid");
                field.setAccessible(true);
                pid = field.getInt(recordProcess);
            } catch (IllegalAccessException | NoSuchFieldException e) {
                e.printStackTrace();
            }

            while(true) {
                ProcessBuilder builder = new ProcessBuilder("kill", "-2", String.valueOf(pid));
                builder.start();
                TestUtils.sleep(1000);
                if(!recordProcess.isAlive()) {
                    break;
                }
            }
        }
    }
}
```

Android,iOSともに、JavaのProcessクラスを使って各端末ごとの録画処理を起動しています。
Javaではpidを取得する処理があんまりなので、複雑になっていますが、やっていることはpidを取得しているだけです。
Androidは録画した後にローカルPCに保存する処理があるのがiOSとの違いになっています。

利用する側は以下のようにして利用ができます。

```java
        try (Record record = recordFactory.createRecord(driver, fileName, BUILD_REPORTS_RECORDS)) {
            record.start();
            // "UIテストの実行"
        }
```

このようにして、Javaでも録画することができます。



# 最後に


まだまだAppiumでのテストケースの開発は初めたばかりで、運用や開発などでいろいろなことが出てくると思うので、また随時紹介したいと思います。