8
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Posted at

何がしたいか

テストコードを書くことを考慮してない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をフックしていたりと、あまり綺麗な解決方法では無い気がしています。
テストしやすい設計って大事ですね。

8
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?