DIを使ってAndroidでイイ感じにテストを書く

はじめに

こんにちは!Androidエンジニアの海藤です。

 

前回に引き続き、今回もAndroidのテストに関連した内容です。ここ半年はテストおじさんという名のもとに、Androidアプリのテストを書くために色々と試行錯誤していますが、その過程でテストを書くためにはDIが非常に重要になってくると感じたため、今回はAndroidのDI事情について書きたいと思います。

DIとテストの関係

DIはDependency Injectionの略で、日本語だと依存性の注入と呼びます。一言で説明すると「コンポーネント間の依存関係を外部から注入する」ですが、これだけだといまいち分からないと思うので、実際のコードを見てみたいと思います。

public class GithubClient {
    private GithubService service;

    public GithubClient() {
        this.service = ApiClientGenerator.generate(
                GithubClient.GithubService.class,
                "https://api.github.com");
    }

    public interface GithubService {
        @GET("/repos/{owner}/{repo}/contributors")
        Observable<List<GithubContributor>> getGithubContributors(
                @Path("owner") String owner, @Path("repo") String repo);
    }
}

public class ApiClientGenerator {
    public static <T> T generate(Class<T> clazz, String baseUrl) {
        return new Retrofit.Builder()
                .client(HttpClient.getInstance())
                .baseUrl(baseUrl)
                .addCallAdapterFactory(RxJavaCallAdapterFactory.create())
                .addConverterFactory(GsonConverterFactory.create())
                .build()
                .create(clazz);
    }
}

上記のコードにおいて、GithubClientはGithubServiceに依存しています。よくある実装かと思いますが、テストを書くという観点で考えてみると、依存関係がベタ書きされている状態だとテスト時に振る舞いを変更出来ないという辛みがあります。具体的には、本番では本番サーバーを参照するが、テストではモックサーバーを参照するということが実現できません。

 

そこで、依存インスタンスをコンストラクタの引数で渡す方式に変更してみます。

public class GithubClient {
    private GithubService service;

    public GithubClient(GithubService service) {
        this.service = service;
    }

    public interface GithubService {
        @GET("/repos/{owner}/{repo}/contributors")
        Observable<List<GithubContributor>> getGithubContributors(
                @Path("owner") String owner, @Path("repo") String repo);
    }
}

上記のように修正すると、GithubClientはGithubServiceというインターフェースにのみ依存することになるので、外部から容易に振る舞いを変更することが出来るようになります。このようにあるクラスが依存しているものを外部から注入することをDependency Injectionと呼びます。そして、テストを書くためにはこのDependency Injectionが非常に重要になります。

DIコンテナ

依存インスタンスを本番とテストで差し替えるための仕組みを自作するのは意外と大変な作業で、これをサポートしてくれるのがDIコンテナです。AndroidではDaggerというDIコンテナが一番よく使われています。Daggerは以下の2種類があって、Squareのものがオリジナルで、Googleのものはパフォーマンスを改善したものになります。

どちらでも同じことが実現できますが、比較的Googleのものが使われることが多く、本記事でもGoogleのもの使用します。

基本編

Daggerでは、Inject、Module、Componentという3つのキーワードが登場します。基本編ではこの3つについて解説します。

Inject

Daggerではインスタンスを注入する箇所に@Injectをつけます。

public class GithubClient {
    private GithubService service;

    @Inject
    public GithubClient(GithubService service) {
        this.service = service;
    }

    public interface GithubService {
        @GET("/repos/{owner}/{repo}/contributors")
        Observable<List<GithubContributor>> getGithubContributors(
                @Path("owner") String owner, @Path("repo") String repo);
    }
}

@Injectはコンストラクタ、フィールド、セッターにつけることが可能で、上記のコードではコンストラクタにインスタンスの注入が行われます。

Module

ModuleはDaggerが注入するインスタンスを生成するクラスになります。

@Module
public class GithubModule {
    @Provides
    public GithubClient.GithubService provideGithubService() {
        return ApiClientGenerator.generate(
                GithubClient.GithubService.class,
                "https://api.github.com");
    }
}

Moduleクラスには@Moduleをつけ、インスタンスを生成するメソッドには@Providesをつける必要があります。

Component

Componentは少し分かりにくいですが、ModuleとInject対象となるクラスの関係を定義します。

@Component(modules = {GithubModule.class})
public interface AppComponent {
    void inject(GithubRepository githubRepository);
}

@Componentで必要なModuleを列挙し、injectメソッドでInject対象のクラスを書くと、そのクラス内で@Injectがついている箇所にインスタンスの注入が行われます。

 

上記のコードでは、GithubRepositoryクラスが必要とするインスタンスをGithubModuleが管理していて、そのインスタンスをGithubRepositoryに注入するという関係性を定義しているのがAppComponentになります。

応用編

Subcomponent

ある程度以上な規模のアプリになると、Componentがどんどん肥大化していきます。具体的には、基本編で登場したAppComponentにInject対象となるクラスを列挙していくと、アプリ内での依存関係がそこに全て集約されて、AppComponentが非常に大きくなってしまうことで管理が煩雑になります。この問題をうまくハンドリングする方法として、DaggerではComponentに階層構造を持たせる機能がサポートされており、これをうまく活用すると依存関係をスマートに管理できます。

 

まず階層化されていない場合の例として、全ての依存関係がAppComponentに列挙されている状態を図にしてみました。

android-di-component

規模が小さいアプリであればこのままでもうまくワークしますが、Moduleが増えるとどんどんAppComponentが肥大化していくことは明白です。そこでAppComponentの内部を機能毎にSubcomponentとして分割してみます。具体例としては、基本編で登場したGithubに関連しているものをGithubComponentとして切り出すと以下のようになります。

@ModuleScope
@Subcomponent(modules = {GithubModule.class})
public interface GithubComponent {
    void inject(GithubRepository githubRepository);
}

上記のようにAppComponentが階層化されている状態を図にすると以下のようになります。

android-di-subcomponent

AppComponentをルートとして、それぞれの画面で使用するComponentがあり、それぞれのComponentがModuleを通してインスタンスを管理するという形になります。

DIを使ったインスタンスの差し替え

基本編で登場したGithubClientをもう一度見てみます。

public class GithubClient {
    private GithubService service;

    @Inject
    public GithubClient(GithubService service) {
        this.service = service;
    }

    public interface GithubService {
        @GET("/repos/{owner}/{repo}/contributors")
        Observable<List<GithubContributor>> getGithubContributors(
                @Path("owner") String owner, @Path("repo") String repo);
    }
}

GithubClientはDaggerによってインスタンスが注入されることになりますが、実際に本番とテストでどのようにインスタンスを差し替えるのかを解説したいと思います。

 

まず、本番は基本編で登場した通りに以下のようなModuleを定義します。

@Module
public class GithubModule {
    @Provides
    public GithubClient.GithubService provideGithubService() {
        return ApiClientGenerator.generate(
                GithubClient.GithubService.class,
                "https://api.github.com");
    }
}

本番はもちろん、 https://api.github.com という本番のエンドポイントを参照します。

 

一方テストでは、モックサーバーを参照するために以下のようなModuleを定義します。

@Module
public class GithubTestModule {
    @Provides
    public GithubClient.GithubService provideGithubService(MockWebServer mockWebServer) {
        return ApiClientGenerator.generate(
                GithubClient.GithubService.class,
                mockWebServer.url("").toString());
    }
}

引数でMockWebServerを受け取り、それを参照するGithubClientを生成しています。ちなみに、MockWebServerはOkHttpというライブラリに含まれているもので、非常に簡単にモックサーバーを立てることが可能です。今回深くは触れないので、詳しくはこちらをご覧ください。

 

そして、テストコードにおけるGithubClientの生成は以下のようになります。

File file = new File("src/test/assets/json/github_contributors.json");
MockWebServer mockWebServer = new MockWebServer();
mockWebServer.enqueue(ResponseUtil.createMockResponse(file));
mockWebServer.start();

GithubTestModule githubTestModule = new GithubTestModule();
GithubClient githubClient = new GithubClient(githubTestModule.provideGithubService(mockWebServer));

ローカルファイルとして保存されているレスポンスデータを返すモックサーバーを生成して、それを参照するGithubClientをGithubTestModule経由で生成しています。これで本番とテストでインスタンスを差し替えるということが実現できます。

 

そして、最終的には以下のようにGithubClientのテストを書くことが可能になります。

TestSubscriber<List<GithubContributor>> testSubscriber = new TestSubscriber<>();
githubClient.getGithubContributors().subscribe(testSubscriber);

testSubscriber.assertNoErrors();
testSubscriber.assertCompleted();
List<GithubContributor> githubContributors = testSubscriber.getOnNextEvents().get(0);
assertThat(githubContributors.size(), is(1));

おわりに

今回はAndroidにおけるDI事情を紹介しました。DIはコンポーネント間の依存関係を外部から注入する仕組みで、テストを書くという観点で考えるとこの仕組みが非常に重要になってきます。また本記事で登場したサンプルコードは以下のリポジトリで公開しているので、興味のある方はご覧ください。

次回はテストが書きやすいアーキテクチャについて書きたいと思います。

  • このエントリーをはてなブックマークに追加

エウレカでは、一緒に働いていただける方を絶賛募集中です。募集中の職種はこちらからご確認ください!皆様のエントリーをお待ちしております!

Recommend

Go言語で覚えるTDD(テスト駆動開発)

Golang におけるサブテストの並行処理実装について