Android、Java、Web系、Linux、マラソン等の備忘録

2013/01/08

onCreate実行前にActivity内メソッドの戻り値を差し替てテストしたい。Mockitoで

0 件のコメント
-------------追記:2015/01/12-------------
書き直しました→Mockitoを使ってActivityの分岐のテスト ~ takaiwa.net
-----------------------------------------
良いやり方ではないと思うんですけど、力業で解決したので載せておきます。
やりたい事はこの質問者さんとだいたい同じだと思います(Activityの話かどうかわかりませんが)
・onCreateメソッドのテスト - Google グループ
https://groups.google.com/forum/#!msg/android-group-japan/0H_wA7IYJ6k/gdqwloJnjR8J
setUp()メソッドをオーバーライドしてgetActivity()を記述しても、その時点でonCreateが呼ばれてしまうんですね。何でonCreate前にそんな事をしたいのかと言うと、上記の質問および下記のサンプルソースから察していただければと思うんですけど、
[テスト対象Activity]
public class MainActivity extends Activity {

    private ServerAPI server_api;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        if(true == isLoggedIn()) {
            ((TextView)findViewById(R.id.textView1)).setText("Logged In");
        }
        else {
            ((TextView)findViewById(R.id.textView1)).setText("Not logged in");
        }
    }

    public boolean isLoggedIn() {
        server_api = new ServerAPI();
        // falseが返る
        return server_api.isConnected();
    }
}

isLoggedIn()はサーバへアクセスするメソッドという風に考えてもらって、戻り値がtrueなら、ログイン済みという旨をTextViewへ表示。falseならログイン未という旨を表示するActivityです。テストの度にサーバへぼこぼこアクセスするのも気が引けますし(※1)、単体テストにログイン/ログアウトの手順を踏むのも違う気が。JUnitのテストプロジェクトから自由にisLoggedIn()の戻り値を書き換えてonCreate内の分岐をテストしたいわけです。が、先ほど申しましたようにgetActivity()時点でonCreate()まで実行済みになってしまう。他に良いAPIがあるのでしょうか?


テストされてる方どういう風にテストコードを書いてらっしゃるのか、ググれどググれどよくわからないので、コメント欄にもでご教授いただければ幸いに存じます。まあそもそも、こんなコードを書くなということなのかもしれませんが、レガシーコードなら普通にありそうですよね?

ということで、試行錯誤してみました。

ActivityのテストだとActivityInstrumentationTestCase2とかSingleLaunchActivityTestCaseやActivityUnitTestCaseになるかと思いますが、ActivityUnitTestCaseでライフサイクルを手動で呼び出せるということで、ソースを読んでstartActivityに注目してみました。

(ソースの場所はダウンロードしていれば、android-sdk/sources/android-16/android/test/ActivityUnitTestCase.java にあります)

このstartActivity内のnewActivityした直後でMockitoのspyを適用すれば、onCreateが実行される前にメソッドの戻り値を差し替えられるのではないかと。

(Mockitoやspyは前回の記事で
http://www.takaiwa.net/2013/01/androidmockito.html

というわけでテストプロジェクトでActivityUnitTestCaseを継承して、startActivityをOverrideするにあたり、startActivityのソースが、
protected T startActivity(Intent intent, Bundle savedInstanceState,
とあって、戻り値の型がTなのですが、これはActivityUnitTestCaseを継承した時のジェネリックの型に合せれば良いです。この場合、
    @Override
    protected MainActivity startActivity(Intent intent,
            Bundle savedInstanceState, Object lastNonConfigurationInstance) {
テスト対象であるMainActivityです。まあEclipseならstartActivityと打ってCtrl + [スペース]のコードアシストを使えばOverrideアノテーションと共に勝手に記述してくれます。

あと、テストプロジェクト上でstartActivityが動くようにいろいろやらないといけないのですが....MockParentがprivateクラスなので、ごっそりAndroidソースから拝借します。とてもエレガントなやり方ではないですね(;^_^A

テスト自体は、spyしたオブジェクトから、isLoggedIn()の戻りがtrueになってることと、TextViewのテキストが、ログイン済みである”Logged In”が表示されることを確認します。あと、細かい点はコメントに書いてます。

package net.takaiwa.mockitosample.test;

import net.takaiwa.mockitosample.R;
import net.takaiwa.mockitosample.MainActivity;
import android.os.Bundle;
import android.os.IBinder;
import android.test.ActivityUnitTestCase;
import android.view.Window;
import android.widget.TextView;
import android.app.Activity;
import android.app.Application;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.spy;
import android.test.mock.MockApplication;
import android.util.Log;
import android.content.ComponentName;

public class MainActivityTest extends ActivityUnitTestCase<MainActivity> {

    private static final String TAG = MainActivityTest.class.getSimpleName();

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

    private TextView text_view;
    private MainActivity spy_activity;
    // --------- startActivity用 ---------------------
    private Context mActivityContext;
    private Class<MainActivity> mActivityClass = MainActivity.class;
    private boolean mCreated = false;
    private boolean mAttached = false;
    private Application mApplication = null;
    private MockParent mMockParent;
    // -----------------------------------------------

    @Override
    protected void setUp() throws Exception {
        super.setUp();
        mActivityContext = getInstrumentation().getTargetContext();
        // Ativity起動(Overrideしたメソッド内部でspyを介してActivityオブジェクトを取得して差し替え)
        startActivity(new Intent(), null, null);
    }

    public void testActivity() throws Exception {
        // 対象のメソッド戻り値を確認しておく
        assertTrue(spy_activity.isLoggedIn());

        text_view = (TextView)spy_activity.findViewById(R.id.textView1);
        // 画面にログイン済みの旨が表示されていることを確認
        assertEquals("Logged In", text_view.getText());
    }

    @Override
    protected MainActivity startActivity(Intent intent,
            Bundle savedInstanceState, Object lastNonConfigurationInstance) {
        assertFalse("Activity already created", mCreated);

        if (!mAttached) {
            assertNotNull(mActivityClass);
            setActivity(null);
            MainActivity newActivity = null;
            try {
                IBinder token = null;
                if (mApplication == null) {
                    // ↓そのまま使うよう書き換え↓
                    mApplication = new MockApplication();
                    setApplication(mApplication);

                }
                ComponentName cn = new ComponentName(mActivityClass.getPackage().getName(),
                        mActivityClass.getName());
                intent.setComponent(cn);
                ActivityInfo info = new ActivityInfo();
                CharSequence title = mActivityClass.getName();
                mMockParent = new MockParent();
                String id = null;

                newActivity = (MainActivity) getInstrumentation().newActivity(mActivityClass, mActivityContext,
                        token, mApplication, intent, info, title, mMockParent, id,
                        lastNonConfigurationInstance);

                ////////////////////////////
                // ↓↓ ここでActivityオブジェクト受け取って書き換え ↓↓
                this.spy_activity = spy(newActivity);
                when(this.spy_activity.isLoggedIn()).thenReturn(true);
                ////////////////////////////

            } catch (Exception e) {
                assertNotNull(newActivity);
            }

//            assertNotNull(newActivity);
//            setActivity(newActivity);
            assertNotNull(this.spy_activity);
            // 差し替え
            setActivity(this.spy_activity);

            mAttached = true;
        }

        MainActivity result = getActivity();
        if (result != null) {
            //ここでonCreateが実行される
            getInstrumentation().callActivityOnCreate(getActivity(), savedInstanceState);
            mCreated = true;
        }
        return result;
    }

    // Androidソースからごっそり拝借
    private static class MockParent extends Activity {

        public int mRequestedOrientation = 0;
        public Intent mStartedActivityIntent = null;
        public int mStartedActivityRequest = -1;
        public boolean mFinished = false;
        public int mFinishedActivityRequest = -1;

        /**
         * Implementing in the parent allows the user to call this function on the tested activity.
         */
        @Override
        public void setRequestedOrientation(int requestedOrientation) {
            mRequestedOrientation = requestedOrientation;
        }

        /**
         * Implementing in the parent allows the user to call this function on the tested activity.
         */
        @Override
        public int getRequestedOrientation() {
            return mRequestedOrientation;
        }

        /**
         * By returning null here, we inhibit the creation of any "container" for the window.
         */
        @Override
        public Window getWindow() {
            return null;
        }

        /**
         * By defining this in the parent, we allow the tested activity to call
         * <ul>
         * <li>{@link android.app.Activity#startActivity(Intent)}</li>
         * <li>{@link android.app.Activity#startActivityForResult(Intent, int)}</li>
         * </ul>
         */
        @Override
        public void startActivityFromChild(Activity child, Intent intent, int requestCode) {
            mStartedActivityIntent = intent;
            mStartedActivityRequest = requestCode;
        }

        /**
         * By defining this in the parent, we allow the tested activity to call
         * <ul>
         * <li>{@link android.app.Activity#finish()}</li>
         * <li>{@link android.app.Activity#finishFromChild(Activity child)}</li>
         * </ul>
         */
        @Override
        public void finishFromChild(Activity child) {
            mFinished = true;
        }

        /**
         * By defining this in the parent, we allow the tested activity to call
         * <ul>
         * <li>{@link android.app.Activity#finishActivity(int requestCode)}</li>
         * </ul>
         */
        @Override
        public void finishActivityFromChild(Activity child, int requestCode) {
            mFinished = true;
            mFinishedActivityRequest = requestCode;
        }
    }
}

実行されてない所とか省けば、もっとスマートになるかと思いますが、1つ2つ確認するのに長いコードになってしまいました(;^_^A これでテスト対象のActivityのメソッドの戻り値を操れます。spyの適用が88行目でisLoggedIn()戻り値の書き換えが89行目です。それを、100行目のsetActivityで差し替えてます。めでたしめでたし。


-- 追記:2013/01/13 --------------------
70行目のmApplication = new MockApplication();のようにしていると、独自にApplicationクラスを継承したクラスをAndroidManifestに定義している場合、Activity上でMyApplication my_application = (MyApplication)this.getApplication();のように取得しようとすると、ClassCastExceptionが発生します。なので、このような場合、startActivity実行前に、
        mApplication = Instrumentation.newApplication(MyApplication.class, mActivityContext);
        mApplication.onCreate();
        setApplication(mApplication);
とすると良いんじゃないかと思います。


※1サーバへぽこぽこアクセスさせないよう差し替えると記述してますが、89行目when(this.spy_activity.isLoggedIn()).thenReturn(true);で戻り値を変更してもonCreate時にそのメソッドの中身は実行されるので、結果としてサーバへはアクセスしてしまいます。実行させないためには、例えば、MainAcctivityの19行目を削除してprivate ServerAPI server_api = new ServerAPI();とし、テストコード89行目の下あたりでそのprivateフィールドを置き換える(ServerAPIで継承してもので)ような工夫が必要となります。そこまで配慮するなら、別のテストライブラリも検討した方がいいかもしれません。詳しくは調べてませんが
-------------------------- 追記終り ---



まあ今回のMainActivityのようなものであれば、onCreateの書き方としてTextViewのsetText()の部分はメソッドとして書き出すのが良いでしょうね。パラメータを渡せるようにして。

    public void setTextView(boolean logged_in) {
        if(true == logged_in) {
            ((TextView)findViewById(R.id.textView1)).setText("Logged In");
        }
        else {
            ((TextView)findViewById(R.id.textView1)).setText("Not logged in");
        }
    }

ここだけテストできますが、バージョンアップの度にテストすることを考えると結局サーバ部分の処理がネックになると思います。onStartに記述できるならそっちへ書いてしまえば、ActivityUnitTestCaseだとonCreateまでで止められるので、メソッドのみのテストもできますが、ライフサイクルとしては(onCreateは生成時のみで、onStartは他のActivityから戻ってきた場合も実行されるなど)アプローチとして問題ですね。

となると、やっぱりテストをするのに、onCreateが実行される前にメソッドを差し替える必要があるわけですが、

そもそもそんな状態はライサイクル的に無く、扱っていいのか疑問な部分ではあるため、テストのやり方としてもうちょっと考えないといけない気がします。
まだまだ修行が必要ですね、テスト駆動開発.....。

0 件のコメント :

コメントを投稿