Espresso+Spoonを使用したUIテストでスクリーンショットを撮るときに困ったこととその対処法について


EspressoとSpoonを組み合わせると、AndroidアプリのUIテストを簡潔に記述することができ、なおかつスクリーンショットも簡単に撮ることができて非常に便利です。
その一方で、「複数のActivityに遷移するようなテストの時、どのようにして各Activityのスクリーンショットを撮ればいいんだろう」、「ダイアログのスクリーンショットを撮るにはどうしたらいいんだろう」といった問題がありました。
この記事では、そのような問題に対して取った対処法を記載しています。

#1.はじめに

## -1.1 Espressoとは
Espressoとは、Googleが2013年に公開したAndroid用のUIテスティングフレームワークです。
Espresso 2.0からは「Android Support Library」の一部として取り込まれており、「SDK Manager」からインストールして簡単に使うことができるようになりました。
Espressoの良い点としては、「Android Suport library」の一部として含まれているため比較的安心して使い続けられそうなこと、簡潔で信頼性のあるUIテストが素早く書けること、などがあります。
その反面、Espresso自体にはスクリーンショットを撮る機能がないため自分で実装する必要がある、といった残念な点もあります。

## -1.2 Spoonとは
そこで[Spoon](http://square.github.io/spoon/)の出番です。Spoonとは、複数のAndroid端末上で同時にテストを実行し、HTML形式で各端末ごとのテスト結果を出力してくれるツールです。
さらに、スクリーンショットを撮る機能も備わっており、以下のようにスクリーンショットを取得したいActivityを指定して、簡単にスクリーンショットを撮ることが可能です。

``` 
Spoon.screenshot(activity, "initial_state");
```

## -1.3 使用バージョン

本記事では、以下のバージョンを前提としています。

* Espresso 2.1
* Spoon v1.2.1

#2.困ったこと

EspressoとSpoonを組み合わせれば、簡潔にUIテストを記述することができて、なおかつActivityのスクリーンショットも簡単に撮れるようになりました。しかし、以下のような困ったことがありました。

## -2.1 複数のActivityに遷移するようなテストだと、それぞれのActivity毎のスクリーンショットが撮れない

Espresso 2.1からはActivityTestRuleが追加されており、これによって以下の例のように`getActivity()`でテスト対象のActivityを取得し、単一のActivityのテストができるようになっています。
しかし、Activityの遷移を行った場合、`getActivity()`では遷移後のActivityは取得できないため、Spoonで遷移後のActivityのスクリーンショットを撮ることができません。
遷移後のActivityのインスタンスを取得できる`getCurrentActivity()`のようなメソッドが必要になります。

``` java
@RunWith(AndroidJUnit4.class)
public class MainActivityTest {

    private Activity mActivity;

    @Rule
    public ActivityTestRule<MainActivity> mActivityRule =
            new ActivityTestRule<MainActivity>(MainActivity.class);

    @Before
    public void setUp() {
        // テスト対象のActivityを取得する
        mActivity = mActivityRule.getActivity();
    }

    @Test
    public void loginShouldSuccessful() {

        // スクリーンショット取得
        Spoon.screenshot(mActivity, "before_login");

        // ログインボタンクリックしてActivitynを遷移を行う
        onView(ViewMatchers.withId(R.id.btnLogin)).perform(click());

        // 遷移後のスクリーンショットを取得したいが以下のコードでは取得できない
        Spoon.screenshot(mActivityRule.getActivity(), "after_login");       
    }

}
```

## -2.2 ダイアログのスクリーンショットが撮れない

Activity上にダイアログが表示されている状態の時にSpoonでスクリーンショットの取得を行うと、取得結果の画像にはダイアログは表示されず、Activityのみが表示されてしまいます。

#3.対処法

## -3.1 getCurrentActivity()を作成する

いくつかの[サイト](http://codingid.blogspot.jp/2014/10/spoon-screenshot-current-activity-and.html)を参考にさせて頂いて、現在表示されているActivityのインスタンスを取得できるgetCurerntActivity()を作成しました。

``` java
    Activity getCurrentActivity() throws Throwable {
        InstrumentationRegistry.getInstrumentation().waitForIdleSync();
        final Activity[] activity = new Activity[1];
        InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() {
            @Override
            public void run() {
                java.util.Collection activities = ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED);
                activity[0] = (Activity) Iterables.getOnlyElement(activities);
            }
        });
        return activity[0];
    }
```

このメソッドを以下のように使用することで、遷移後のActivityのスクリーンショットが撮れるようになりました。

``` 
        // スクリーンショット取得
        Spoon.screenshot(mActivity, "before_login");

        // ログインボタンクリックしてActivityを遷移を行う
        onView(ViewMatchers.withId(R.id.btnLogin)).perform(click());

        // 遷移後のスクリーンショットを取得する
        Spoon.screenshot(getCurrentActivity(), "after_login");  
```

## -3.2 ダイアログ用のスクリーンショット取得メソッドを使用する

SpoonのGitHubリポジトリの[Issues](https://github.com/square/spoon/issues/4)で、ダイアログのスクリーンショット取得用のメソッドを書いてくれている人がいました。
別のテスティングフレーウワークであるRobotiumだとダイアログのスクリーンショットも問題なく取得できるようで、そのコードを流用しているようです。
スクリーンショットの保存先もSpoon標準の保存先に変更しており、SpoonのテストレポートHTMLにも問題なくスクリーンショットが表示されます。

※ 一部バージョンの問題があるようで、KitKat以降のOSだとエラーが出て動きませんでした。その部分に関しては、Robotiumnの方のソースを参考にして以下のように修正すると正常に動くようになりました。

```
    public View[] getWindowDecorViews()
    {

        Field viewsField;
        Field instanceField;
        try {
            viewsField = windowManager.getDeclaredField("mViews");
            instanceField = windowManager.getDeclaredField(windowManagerString);
            viewsField.setAccessible(true);
            instanceField.setAccessible(true);
            Object instance = instanceField.get(null);
           
            //return (View[]) viewsField.get(instance);
            View[] result;
            if (android.os.Build.VERSION.SDK_INT >= 19) {
                result = ((ArrayList<View>) viewsField.get(instance)).toArray(new View[0]);
            } else {
                result = (View[]) viewsField.get(instance);
            }
            
            return result;
        } catch (SecurityException e) {
            e.printStackTrace();
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalArgumentException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
        return null;
    }
```