何がしたいか
テストコードを書くことを考慮してないFragmentのコードに対して、テストコードを書いていきたい。既存コードを変更せずに。
テストとしては例えば、APIアクセスの結果、特定のエラーレスポンスの場合は別画面に遷移するとか。
既存コードの仕組み
OkHttp+RetrofitでAPIアクセスを管理しています。
また、APIの接続先が本番・ステージング・開発の3環境あるので、strings.xml
を各build variantsのディレクトリに置いています。
ApiClientというクラスで、Retrofitのサービスインスタンスを生成しています。
(ホントは、DaggerとかでDIしたほうが良さそうですが。)
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の方では、クリックイベントなどを契機に、下記のように呼び出します。
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継承クラスを指定します。
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
を初期化するメソッドを提供します。
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
という別クラスだったようですが、バージョンアップのタイミングで統合されたようです。)
@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クラスで設定していた CustomRobolectricTestRunner
と ShadowOkHttpClient
が必要になってきます。
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();
}
}
@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」を設定し、それを返却しています。
テストを記述する
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
をフックしていたりと、あまり綺麗な解決方法では無い気がしています。
テストしやすい設計って大事ですね。