OkHttpのMockWebServerとRobolectricでFragmentの動作をテストする

  • 5
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

何がしたいか

テストコードを書くことを考慮してないFragmentのコードに対して、テストコードを書いていきたい。既存コードを変更せずに。
テストとしては例えば、APIアクセスの結果、特定のエラーレスポンスの場合は別画面に遷移するとか。

既存コードの仕組み

OkHttp+RetrofitでAPIアクセスを管理しています。
また、APIの接続先が本番・ステージング・開発の3環境あるので、strings.xmlを各build variantsのディレクトリに置いています。

ApiClientというクラスで、Retrofitのサービスインスタンスを生成しています。
(ホントは、DaggerとかでDIしたほうが良さそうですが。)

ApiClient.java
public class ApiClient {
    private static ApiClient sApiClient;

    private HogeService hogeService;

    private ApiClient() {
    }

    public static ApiClient getInstance() {
        if (sApiClient == null) {
            sApiClient = new ApiClient();
        }
        return sApiClient;
    }

    public void initialize(final Context c) {
        OkHttpClient okHttpClient = new OkHttpClient().newBuilder()
                .addInterceptor(new HttpLoggingInterceptor().setLevel(BuildConfig.DEBUG ? HttpLoggingInterceptor.Level.BODY : HttpLoggingInterceptor.Level.NONE))
                .build();

        Retrofit retrofit = new Retrofit.Builder()
                .baseUrl(context.getString(R.string.api_base_url))
                .addConverterFactory(GsonConverterFactory.create())
                .client(okHttpClient)
                .build();

        hogeService = retrofit.create(HogeService.class);
    }

    public HogeService getHogeService() {
        return hogeService;
    }
}

上記のinitializeメソッドは、Application継承クラスから、onCreateのタイミングで呼びだされます。

APIを実行するFragmentの方では、クリックイベントなどを契機に、下記のように呼び出します。

SomeFragment.java
ApiClient.getInstance().getHogeService().login(loginInfo).enqueue(new Callback<UserInfo>() {
    @Override
    public void onResponse(Call<UserInfo> call, Response<UserInfo> response) {
        if (response.code() == 401) {
            Intent intent = new Intent(activity, SignUpActivity.class);
            startActivity(intent);
            return;
        }
        // ログイン成功したときの処理
    }

    @Override
    public void onFailure(Call<UserInfo> call, Throwable t) {
    }
});

上記では、例として、「ログインAPIが401だったら登録Activityを表示する」としています。
これを コードを変更せずに テストコードを書いてみます。(既存コードの問題点はスルーし、テストを書くことを優先)

基本方針

  • Robolectricを利用して、Fragmentの生成、イベントの発生を行う。
  • OkHttpのMockWebServerを利用して、APIレスポンスを偽装する。
  • MockitoやRobolectricのShadowを使い、本番コードを修正しないようにテストを書く。

やったこと

build.gradle

build.gradleでは、以下のようにテスト用の各種ライブラリを読み込んでいます。

    testCompile 'junit:junit:4.12'
    testCompile 'org.mockito:mockito-core:1.10.19'
    testCompile "org.robolectric:robolectric:3.1"
    testCompile "org.robolectric:shadows-support-v4:3.1"
    testCompile "org.robolectric:shadows-multidex:3.0"
    testCompile "com.squareup.okhttp3:mockwebserver:3.4.1"

MockWebServerと通信させるため、RetrofitのbaseUrlを書き換える

robolectric.propertiesを作成し、テスト用のApplication継承クラスを指定します。

robolectric.properties
application=com.hoge.StubApp
sdk=23

また、SDKを23にすることでNoClassDefFoundErrorが発生してしまったので、GLインターフェースを定義だけしておきます。
参考: https://github.com/robolectric/robolectric-gradle-plugin/issues/145#issuecomment-95456798

package javax.microedition.khronos.opengles;

public interface GL {
}

StubAppクラス内で、Mockitoのspyを利用して、改ざんしたURLでApiClientを初期化するメソッドを提供します。

StubApp.java
public class StubApp extends Application {

    public void initializeBaseUrl(MockWebServer server) {
        StubApp spiedApp = spy(this);
        when(spiedApp.getString(R.string.api_base_version)).thenReturn(server.url("/api/1.0.0/").toString());

        ApiClient.getInstance().initialize(spiedApp);
    }
}

これで、initializeBaseUrlを実行すると、Fragment内などで取得できるHogeServiceインスタンスの向き先を変えることが出来ました。

MockWebServerを起動・停止する

MockWebServerはTestRuleを継承しており、@Ruleを付けておくことで、自動で起動・停止を行ってくれます。

(以前はMockWebServerRuleという別クラスだったようですが、バージョンアップのタイミングで統合されたようです。)

SomeFragmentTest.java
@RunWith(CustomRobolectricTestRunner.class)
@Config(shadows = {ShadowOkHttpClient.class})
public class SomeFragmentTest {
    @Rule
    public MockWebServer server = new MockWebServer();

    private SomeFragment fragment;

    @Before
    public void setup() throws IOException {
        fragment = new SomeFragment();
        startFragment(fragment, SomeActivity.class);

        ((StubApp) RuntimeEnvironment.application).initializeBaseUrl(server);
    }
...

APIのcallbackを即座に実行するようにする

通常、enqueueしたretrofit2.Callbackの各メソッドは、非同期に実行されます。
そのままだとコールバックを待たずにassertを行うことになってしまい、正しい結果が得られません。
そのため、Testクラスで設定していた CustomRobolectricTestRunnerShadowOkHttpClient が必要になってきます。

CustomRobolectricTestRunner.java
public class CustomRobolectricTestRunner extends RobolectricGradleTestRunner {
    public CustomRobolectricTestRunner(Class<?> klass) throws InitializationError {
        super(klass);
    }

    @Override
    protected ShadowMap createShadowMap() {
        return super.createShadowMap().newBuilder()
                .addShadowClass(ShadowOkHttpClient.class)
                .build();
    }

    public InstrumentationConfiguration createClassLoaderConfig() {
        return InstrumentationConfiguration.newBuilder()
                .addInstrumentedClass(OkHttpClient.class.getName())
                .build();
    }
}
ShadowOkHttpClient.java
@Implements(OkHttpClient.class)
public class ShadowOkHttpClient {
    @RealObject
    OkHttpClient realObject;

    @Implementation
    public OkHttpClient.Builder newBuilder() {
        return Shadow.directlyOn(realObject, OkHttpClient.class)
                .newBuilder()
                .dispatcher(new Dispatcher(new AbstractExecutorService() {
                    private boolean shuttingDown = false;
                    private boolean terminated = false;

                    @Override
                    public void shutdown() {
                        this.shuttingDown = true;
                        this.terminated = true;
                    }

                    @NonNull
                    @Override
                    public List<Runnable> shutdownNow() {
                        return new ArrayList<>();
                    }

                    @Override
                    public boolean isShutdown() {
                        return this.shuttingDown;
                    }

                    @Override
                    public boolean isTerminated() {
                        return this.terminated;
                    }

                    @Override
                    public boolean awaitTermination(long timeout, @NonNull TimeUnit unit) throws InterruptedException {
                        return false;
                    }

                    @Override
                    public void execute(@NonNull Runnable command) {
                        command.run();
                    }
                }));
    }
}

CustomRobolectricTestRunnerでは、OkHttpClientが利用されたときにShadowOkHttpClientを利用するように設定しています。
ShadowOkHttpClientでは、newBuilderメソッドを書き換えています。
本来newBuilderで返却するはずだったものに、「即時実行するdispatcher」を設定し、それを返却しています。

テストを記述する

HogeFragment.java
public class SomeFragmentTest {
...
    @Test
    public void clickButton_showSignUpActivity() throws Exception {
        server.enqueue(new MockResponse()
                .setResponseCode(401)
                .setBody("{\"message\":\"ユーザーが存在しません。\"}"));

        fragment.getView().findViewById(R.id.some_button).performClick();

        assertThat(server.takeRequest().getPath(), is("/login"));

        Intent intent = Shadows.shadowOf(fragment.getActivity()).getNextStartedActivity();
        Intent expect = new Intent(fragment.getActivity(), SignUpActivity.class);
        assertThat(intent.filterEquals(expect), is(true));
    }

※実際のコードからかなり改変しているので、動かなかったらごめんなさい。

some_buttonを押した時に、前述の「ログインAPIが401だったら登録Activityを表示する」機能があると思ってください。
performClickで、クリックイベントを擬似的に発生させ、getNextStartedActivityで次に遷移するActivityを取得、それが正しいかを確認しています。

まとめ

MockitoやRobolectricのShadowを利用することで、既存のコードを書き換えず、ある程度複雑になってしまっているFragmentのテストが出来そうです。
ただ、newBuilderをフックしていたりと、あまり綺麗な解決方法では無い気がしています。
テストしやすい設計って大事ですね。