【Android】Espressoを使ってUIをテストする

# はじめに
私はまだまだテスト初心者です．
単体テストすらろくに書いたことが無いので，皆様の知恵など色々お貸し頂ければと思います．
編集リクエストも遠慮無くどうぞ．
ここに記載しているコードのライセンスは [NYSL](http://www.kmonos.net/nysl/) でどうぞ．

# Espressoとは
2013.10にGoogleが公開したAndroid用のUIテスティングフレームワークですね．
まだ公開されて間もないできたてホヤホヤの激熱テストツールです（エスプレッソだけに）
短いコードでアクションだったり評価したり，処理が終わるまで待ってテストしたりということを自動でやってくれます．

# Espresso導入
ここからEspressoのjarを落として，そのままテストプロジェクトのlibsディレクトリに突っ込んで終わりです．
https://code.google.com/p/android-test-kit/source/browse/#git%2Fbin%2Fespresso-standalone

# Espresso前準備

## AndroidManifest.xmlの<manifest>タグの中に下記コードを追加します．

targetPackageのところはテスト対象となるアプリのパッケージ名を入力してください．
android.permission.SET_ANIMATION_SCALE のパーミッションは，アニメーションの実行速度を変更するパーミッションです．
毎回開発者オプションから変更するのは面倒なので，テストコード実行前に変更を行い，テスト後に元に戻すようにします．

```xml:AndroidManifest.xml
<uses-permission android:name="android.permission.SET_ANIMATION_SCALE" />
<instrumentation
    android:name="com.google.android.apps.common.testing.testrunner.GoogleInstrumentationTestRunner"
    android:targetPackage="com.example.appli" />
```

## Instrumentation runnerを変更します

1. Eclipseのテストプロジェクトを右クリックして実行(run)→実行の構成(Run Configuration)を開きます．
1. 左ペインの Android JUnit Test から，目的のテストプロジェクトを選択します．
（一覧に無い場合は左上のアイコンから新規作成を行います．）
1. テスト(Test)タブのRun all tests in the selected project, or packageにチェックを入れます．
1. 新規作成した場合は，名前を設定し，テスト対象のプロジェクトを選択します．
1. Instrumentation runner:で「com.google.android.apps.common.testing.testrunner.GoogleInstrumentationTestRunner」を選択します．
1. 適用(Apply)をクリックして完了です．

なお，どうやらGoogleInstrumentationTestRunnerへ変更すると，クラス単位やメソッド単位でのテストができなくなるようです．

## Activityテストのためのテンプレート

* setUpメソッド

ここはテストを開始する前に呼び出されるメソッドです．
オーバーライドしたgetActivity()をコールしてActivityを取得します．このとき，Activityも作成されます．
さらにTestUtils#toggleAnimationEnable()をコールます．（TestUtils.javaについては下記参照）

* getActivityメソッド

getActivity()をオーバーライドしていますが，これは呼び出すActivityへIntent情報などを渡したい場合にオーバーライドすれば良く，何も指定する必要が無い場合はオーバーライドしなくて構いません．
今回は IntentにBundleで情報を渡したいと仮定してオーバーライドしています．

* tearDownメソッド

tearDown()はテスト終了後に行う処理です．
今回はsetUp()で無効化したアニメーションをここで有効化（元にもど）します．

* test*メソッド

後は通常のJUnit同様テストメソッドにシナリオを書いていくだけです．
が，Activity起動～終了の1サイクルを1シナリオテストとして，複数のシナリオテストをしたい場合は，同じように別のTestクラスを作成していきます．

```java:ActivityTest.java

package com.example.appli;

import android.app.Activity;
import android.content.Intent;
import android.test.ActivityInstrumentationTestCase2;

import com.example.appli.TestUtils;

public class MainActivityTest extends ActivityInstrumentationTestCase2<TestActivity> {
    private static final String TAG = MainActivityTest.class.getSimpleName();
    private Activity mActivity;

    public MainActivityTest() {
        super(TestActivity.class);
    }

    @Override
    public void setUp() throws Exception {
        super.setUp();
        mActivity = getActivity();
        TestUtils.toggleAnimationEnable(mActivity, TAG, false);
    }

    @Override
    public TestActivity getActivity() {
        Intent intent = new Intent();
        intent.putExtra("key", "value");
        setActivityIntent(intent);
        return super.getActivity();
    }

    @Override
    protected void tearDown() throws Exception {
        TestUtils.toggleAnimationEnable(mActivity, TAG, true);
        super.tearDown();
    }

    public void testStory() throws Exception {
        // TODO ここにテストを書く
    }
}
```

### TestUtils (コピペでOKです)
ウィンドウアニメーションスケールとトランジションアニメーションスケールを変更します．
上記のMainActivityTest.javaではsetUp()とtearDown()から呼び出しています．

https://code.google.com/p/android-test-kit/wiki/DisablingAnimations の情報を元に少しだけ手を加えています．

```java:TestUtils.java

package com.example.appli;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

import android.content.Context;
import android.content.pm.PackageManager;
import android.os.IBinder;
import android.util.Log;

public final class TestUtils {

    private static final int RESULT_ENABLED = 0;
    private static final int RESULT_DISABLED = 1;
    private static final int RESULT_NG = -1;

    /**
     * EspressoでUIテストを実行する場合は setUp メソッドで無効にし，tearDownで有効にしてください
     * 
     * @param context
     * @param TAG
     * @param isEnable
     */
    public static void toggleAnimationEnable(Context context, String TAG, boolean isEnable) {
        int permStatus = context
                .checkCallingOrSelfPermission("android.permission.SET_ANIMATION_SCALE");
        if (permStatus == PackageManager.PERMISSION_GRANTED) {
            int result;
            if ((result = reflectivelyToggleAnimation(TAG, isEnable)) != RESULT_NG) {
                if (result == RESULT_ENABLED) {
                    Log.i(TAG, "All animations enabled.");
                } else {
                    Log.i(TAG, "All animations disabled.");
                }
            } else {
                Log.i(TAG, "Could not toggle animations.");
            }
        } else {
            Log.i(TAG, "Cannot disable animations due to lack of permission.");
        }
    }

    private static int reflectivelyToggleAnimation(String TAG, boolean isEnable) {
        try {
            Class<?> windowManagerStubClazz = Class.forName("android.view.IWindowManager$Stub");
            Method asInterface = windowManagerStubClazz.getDeclaredMethod("asInterface",
                    IBinder.class);
            Class<?> serviceManagerClazz = Class.forName("android.os.ServiceManager");
            Method getService = serviceManagerClazz.getDeclaredMethod("getService", String.class);
            Class<?> windowManagerClazz = Class.forName("android.view.IWindowManager");
            Method setAnimationScales = windowManagerClazz.getDeclaredMethod("setAnimationScales",
                    float[].class);
            Method getAnimationScales = windowManagerClazz.getDeclaredMethod("getAnimationScales");

            IBinder windowManagerBinder = (IBinder) getService.invoke(null, "window");
            Object windowManagerObj = asInterface.invoke(null, windowManagerBinder);
            float[] currentScales = (float[]) getAnimationScales.invoke(windowManagerObj);
            for (int i = 0; i < currentScales.length; i++) {
                if (isEnable) {
                    currentScales[i] = 1.0f;
                } else {
                    currentScales[i] = 0.0f;
                }
            }
            setAnimationScales.invoke(windowManagerObj, currentScales);

            return isEnable ? RESULT_ENABLED : RESULT_DISABLED;
        } catch (ClassNotFoundException cnfe) {
            Log.w(TAG, "Cannot disable animations reflectively.", cnfe);
        } catch (NoSuchMethodException mnfe) {
            Log.w(TAG, "Cannot disable animations reflectively.", mnfe);
        } catch (SecurityException se) {
            Log.w(TAG, "Cannot disable animations reflectively.", se);
        } catch (InvocationTargetException ite) {
            Log.w(TAG, "Cannot disable animations reflectively.", ite);
        } catch (IllegalAccessException iae) {
            Log.w(TAG, "Cannot disable animations reflectively.", iae);
        } catch (RuntimeException re) {
            Log.w(TAG, "Cannot disable animations reflectively.", re);
        }
        return RESULT_NG;
    }

    private TestUtils() {
        // インスタンス化の禁止
    }

}
```

# Espresso UI テストの基本

やっと前準備が終わりました．あとはゴリゴリテストを書いていくだけです．
基本的なメソッドは下に列挙していきます．

## Espresso.onView(Matcher<View> matcher)

Matcherを渡すと，一致したViewのViewInteractionを返します．
このViewInteractionに対して，クリックとか値のチェックなどを指定できます．

## ViewMatchers.withId(int id)

いつものfindViewByIdと同じく，ViewのIDを渡すと一致したViewのViewMatcherを返します．1

```java:e.g.
Espresso.onView(ViewMatchers.withId(R.id.button))
```

のように記述します．

## ViewInteraction.perform(ViewAction...)

ViewInteractionのメソッドです．引数で渡されたViewActionを実行します．
例えば，渡されたViewActionがViewActions.click()なら，そのViewをクリックします．

```java:e.g.
Espresso.onView(ViewMatchers.withId(R.id.button)).perform(ViewActions.click());
```

## ViewInteraction.check(ViewAssertion)

ViewInteractionのメソッドです．引数で渡されたViewAssertionでViewの状態を検査します．

```java:e.g.
// R.id.button が表示されていることをチェックします．
Espresso.onView(ViewMatchers.withId(R.id.button)).check(ViewAssertions.matches(ViewMatchers.isDisplayed()));
```

```java:e.g.
// R.id.button が表示されていないことをチェックします．
// notメソッドは org.hamcrest.Matchers.not
Espresso.onView(ViewMatchers.withId(R.id.button)).check(ViewAssertions.matches(Matchers.not(ViewMatchers.isDisplayed())));
```

## ViewMatchers.isDisplayed()

その名の通り，Viewが表示されているかを返すメソッドです．

## ViewMatchers.withText()

Viewが持っているテキストが一致するかを返すメソッドです．

## Tips

ViewMatchersやViewAssertions等，大量にstaticメソッドを利用するので，staticインポートしてしまった方がコードが短くなります．
EclipseならViewMathers.withIdなど，staticメソッドにカーソルを合わせて Ctrl + Shift + Mで自動的にstaticインポートしてくれます．
staticインポートすると，このようになります

```java:before
Espresso.onView(ViewMatchers.withId(R.id.button)).check(ViewAssertions.matches(Matchers.not(ViewMatchers.isDisplayed())));
```

```java:after
onView(withId(R.id.button)).check(matches(not(isDisplayed())));
```

# 欲しい ViewMatcherやViewActionが無い場合

歴史が浅いためか，見た感じ欲しいViewMatherやViewActionが無かったりします．
この場合は自分で書くようです．
例えば，前方一致でTextViewの値を検査したいとしますが，ViewMatchersにはそれらしきメソッドがありません．
(というか，このくらいありそうな物ですが，なにか方法があれば教えてください)

## ViewMatcherを自作する

というわけで，いくつかのViewMatcherを作ってみます．
（あまり自信ないので，おかしなところ有ればご指摘ください）

```java:EspressoUtils.java
package com.example.appli.espresso;

import static org.hamcrest.Matchers.is;

import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.Matchers;

import android.view.View;
import android.widget.ProgressBar;
import android.widget.TextView;

import com.google.android.apps.common.testing.ui.espresso.matcher.BoundedMatcher;

public final class EspressoUtils {
    
    /**
     * TextViewの文字列を前方一致で検査
     * 
     * @param expectString
     * @return
     */
    public static Matcher<View> startsWith(final String expectString) {
        final Matcher<String> textMatcher = is(expectString);
        return new BoundedMatcher<View, TextView>(TextView.class) {

            @Override
            protected boolean matchesSafely(TextView textView) {
                CharSequence text = textView.getText();
                if (text == null) {
                    return text == expectString;
                }
                return expectString.startsWith(text.toString());
            }

            @Override
            public void describeTo(Description description) {
                description.appendText("start text : ");
                textMatcher.describeTo(description);
            }

        };
    }

    /**
     * TextViewの文字列を後方一致で検査
     * 
     * @param expectString
     * @return
     */
    public static Matcher<View> endsWith(final String expectString) {
        final Matcher<String> textMatcher = is(expectString);
        return new BoundedMatcher<View, TextView>(TextView.class) {

            @Override
            protected boolean matchesSafely(TextView textView) {
                CharSequence text = textView.getText();
                if (text == null) {
                    return text == expectString;
                }
                return expectString.endsWith(text.toString());
            }

            @Override
            public void describeTo(Description description) {
                description.appendText("end text : ");
                textMatcher.describeTo(description);
            }

        };
    }

    /**
     * ProgressBarのプログレスを前方一致で検査
     * 
     * @param expectString
     * @return
     */
    public static Matcher<View> withProgress(final Integer expectProgress) {
        final Matcher<Integer> progressMatcher = is(expectProgress);
        return new BoundedMatcher<View, ProgressBar>(ProgressBar.class) {

            @Override
            protected boolean matchesSafely(ProgressBar progressBar) {
                return progressMatcher.matches(progressBar.getProgress());
            }

            @Override
            public void describeTo(Description description) {
                description.appendText("with progress : ");
                progressMatcher.describeTo(description);
            }
        };
    }
    
    private EspressoUtils() {
    }
}
```

みたいな感じでしょうか．
テキストの前方，後方一致と，ProgressBarのプログレスの値を検査するメソッドを作ってみました．
なお，ButtonやEditTextはTextViewを，SeekBarやRatingBarはProgressBarを継承しているため，同じ検査メソッドで利用できます．

# シナリオテスト

いよいよシナリオテストですが，実際は上のコードを組み合わせて，Viewを操作して状態を確認して…の繰り返しです．
簡単に書いてみます．
ここでは，MainActivityにEditTextとButtonを持ち，EditTextに入力した内容がSubActivityに送られて，そこのTextViewに文字列が表示される．ということで書いてみます．

## MainActivity

```java:MainActivity.java
package com.example.test;

import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.ViewGroup.LayoutParams;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;

public class MainActivity extends Activity {

    private static final int WC = LayoutParams.WRAP_CONTENT;
    private static final int MP = LayoutParams.MATCH_PARENT;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        final LinearLayout rootLayout = new LinearLayout(this);
        rootLayout.setOrientation(LinearLayout.VERTICAL);
        rootLayout.setLayoutParams(new LayoutParams(MP, MP));
        setContentView(rootLayout);

        // EditText
        final EditText editText = new EditText(this);
        editText.setId(R.id.editText);
        editText.setLayoutParams(new LayoutParams(MP, WC));
        editText.setHint("'test'で始まる文字列か，'123'で終わる文字列か，'abc'を入力してください");
        rootLayout.addView(editText);

        // Button
        final Button button = new Button(this);
        button.setId(R.id.button);
        button.setLayoutParams(new LayoutParams(WC, WC));
        button.setText("OK");
        rootLayout.addView(button);

        button.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                String input = editText.getText().toString();
                Intent intent = new Intent();
                intent.putExtra("input", input);
                intent.setClassName(getApplicationContext(), "com.example.test.SubActivity");
                startActivity(intent);
            }
        });
    }
}
```

## SubActivity.java

```java:SubActivity.java
package com.example.test;

import android.app.Activity;
import android.os.Bundle;
import android.view.ViewGroup.LayoutParams;
import android.widget.LinearLayout;
import android.widget.TextView;

public class SubActivity extends Activity {

    private static final int WC = LayoutParams.WRAP_CONTENT;
    private static final int MP = LayoutParams.MATCH_PARENT;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        final LinearLayout rootLayout = new LinearLayout(this);
        rootLayout.setOrientation(LinearLayout.VERTICAL);
        rootLayout.setLayoutParams(new LayoutParams(MP, MP));
        setContentView(rootLayout);

        Bundle bundle = getIntent().getExtras();
        String text = bundle.getString("input");

        // TextView
        final TextView textView = new TextView(this);
        textView.setId(R.id.text);
        textView.setLayoutParams(new LayoutParams(WC, WC));
        textView.setText(text);
        rootLayout.addView(textView);

    }
}
```

## /res/values/view_ids.xml

```xml:view_ids.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <item name="editText" type="id"/>
    <item name="button" type="id"/>
    <item name="text" type="id"/>
</resources>
```

## MainActivityTest.java

```java:MainActivityTest.java
package com.example.test;

import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView;
import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.click;
import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.typeText;
import static com.google.android.apps.common.testing.ui.espresso.assertion.ViewAssertions.matches;
import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withId;
import android.app.Activity;
import android.content.Intent;
import android.test.ActivityInstrumentationTestCase2;

import com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers;
import com.example.test.TestUtils;

public class MainActivityTest extends ActivityInstrumentationTestCase2<MainActivity> {
    private static final String TAG = MainActivityTest.class.getSimpleName();
    private Activity mActivity;

    public MainActivityTest() {
        super(MainActivity.class);
    }

    @Override
    public void setUp() throws Exception {
        super.setUp();
        mActivity = getActivity();
        TestUtils.toggleAnimationEnable(mActivity, TAG, false);
    }

    @Override
    protected void tearDown() throws Exception {
        TestUtils.toggleAnimationEnable(mActivity, TAG, true);
        super.tearDown();
    }

    public void testStory() throws Exception {
        onView(withId(R.id.editText)).perform(typeText("123456"));
        onView(withId(R.id.button)).perform(click());
        onView(withId(R.id.text)).check(matches(ViewMatchers.withText("123456")));
    }
}
```

これでプロジェクトファイルを右クリック→実行→Android JUnit Testを選択するとテストが実行されます．
ちょっと速いですが，画面の動きを見ると，ちゃんとEditTextに文字列が入力され，次のSubActivityへ遷移して，そこのTextViewに入力された文字列が表示される事が分かります．
もちろん，JUnitの検査バーはグリーンになるはずです．

ところで，EditTextのヒントに入力文字列の条件が書かれています．
コレを次のSubActivityのTextViewから取得してテストします…しようと思ったんですが，力尽きました．

なので，みなさんぜひ挑戦してみてください．

# Tips

つかれたので，あとでまとめます…（あんまりないけど）
Activity遷移後のActivityインスタンスのとりかたとか…