既存コードをAndroid非依存なクラスに抽象化してユニットテストするための第一歩

これは Android その2 Advent Calendar の6日目の記事です。

はじめに

ActivityやFragmentにビジネスロジックがありユニットテストしずらいコードに対して、Android非依存なクラスに抽象化することでテスタブルにする方法の紹介です。
今回はMVPアーキテクチャにすることでActivityに書いていたロジックをPresenterに抽象化してそこにテストを書いていきます。

注意点

実際に業務でアーキテクチャを選定する際は、プロダクトの規模、チームメンバーの技術力、事業の優先度などを考慮して上で決めたほうがいいので、必ずしもこのやり方がベストというわけではありません。
例えばプロダクトの立ち上げ時期はスピード優先の場合が多いので、この内容は適さない場合もあります。

やりたいこと(イメージ)

前提知識

MVPアーキテクチャとは

Model-View-Presenterで分けたアーキテクチャのことです。
Androidでは一般的にPresentation層にViewとなるActivity/FragmentからPresenterを呼び出して、PresenterからRepositoryを呼び出すことが多いです。
処理イメージとしては以下です。


(引用)https://github.com/googlesamples/android-architecture/tree/todo-mvp/

Android非依存なクラスとは

Android Frameworkに依存していないクラスのことです。
具体的にはContextなどに依存してないピュアJava/Kotlinのクラスを指します。

サンプルアプリ

GitHubのリポジトリ一覧を表示するアプリです。
初期状態ではボタンが表示されていて、ボタンを押すと googlesamples のリポジトリ一覧を表示するという非常にシンプルなものです。
また、簡略化のため追加読み込み、ローディングインジケータなどは実装してません。

初期状態 ボタン押下後

このアプリのリポジトリはこちらです。
https://github.com/rkowase/android-mvp-sample

今回テストしたいActivity

今後の機能拡張につれてFatになりそうなActivityとして作ってみました。
RetrofitでServiceクラスを作って、ボタンが押されたらGitHubのAPIを叩いて成功したらリスト表示とボタン非表示、失敗したらエラーメッセージ表示をしています。

MainActivity.kt
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val service = createService()

        button.setOnClickListener({
            service.listRepos(getString(R.string.user))
                    .subscribeOn(Schedulers.newThread())
                    .observeOn(AndroidSchedulers.mainThread())
                    .subscribe({
                        if (it.isEmpty()) {
                            showError()
                            return@subscribe
                        }
                        showList(it)
                        hideButton()
                    }, {
                        showError()
                    })
        })
    }

    private fun createService(): GitHubService {
        val retrofit = Retrofit.Builder()
                .baseUrl(getString(R.string.base_url))
                .addConverterFactory(MoshiConverterFactory.create())
                .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
                .build()

        return retrofit.create(GitHubService::class.java)
    }

    private fun hideButton() {
        button.visibility = View.GONE
    }

    private fun showError() {
        Toast.makeText(this, getString(R.string.error_message), Toast.LENGTH_LONG).show()
    }

    private fun showList(it: List<RepoEntity>) {
        var list = listOf<String>()
        it.forEach { list += it.name }
        val adapter = ArrayAdapter<String>(this, android.R.layout.simple_list_item_1, list)
        listView.adapter = adapter
    }
}
GitHubService.kt
interface GitHubService {
    @GET("users/{user}/repos")
    fun listRepos(@Path("user") user: String): Observable<List<RepoEntity>>
}

テスタブルにしていく工程

Repositoryに分離する

まずはactivityのcreateService()をRepositoryに切り分けるためにinterfaceを作ります。

GitHubRepository.kt
interface GitHubRepository {
    fun initService()
    fun request(user: String): Observable<List<RepoEntity>>
}

次に作ったinterfaceを実装していきます。
具体的には元々View側(MainActivity)に実装していた処理を移していきます。

GitHubRepositoryImpl.kt
class GitHubRepositoryImpl(private val mContext: Context): GitHubRepository {
    private lateinit var mService: GitHubService

    override fun initService() {
        val retrofit = Retrofit.Builder()
                .baseUrl(mContext.getString(R.string.base_url))
                .addConverterFactory(MoshiConverterFactory.create())
                .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
                .build()

        mService = retrofit.create(GitHubService::class.java)
    }

    override fun request(user: String): Observable<List<RepoEntity>> = mService.listRepos(user)
}

Presenterに分離する

まずはViewとPresenterのinterfaceを作ります。
今後画面が増えていくことを想定してBaseViewとBasePresenterを作っておきます。
MVPアーキテクチャではViewはPresenterを持つのでこのように定義しておきます。

BaseView.kt
interface BaseView<T> {
    var presenter: T
}
BasePresenter.kt
interface BasePresenter {
    fun start()
}

次に今回作る画面用のinterfaceを作ります。
ViewとPresenterは画面に紐づく(画面と1:1の関係)ので、一つのクラス(GitHubContract)としてまとめてその中にBaseクラスを継承したViewとPresenterのinterfaceを定義します。

GitHubContract.kt
class GitHubContract {
    interface View: BaseView<Presenter> {
        fun showList(list: List<RepoEntity>)
        fun showError()
        fun hideButton()
    }

    interface Presenter: BasePresenter {
        fun request(user: String)
    }
}

最後にPresenterを実装していきます。
PresenterではViewでやるべき操作を抽象化して書いていきます。
Viewでの操作の詳細(例えばどのようなエラー表示をするかなど)はViewに任せます(移譲します)。

GitHubPresenter.kt
class GitHubPresenter(
        private val mRepository: GitHubRepository,
        private val mView: GitHubContract.View) : GitHubContract.Presenter {

    private lateinit var mService: GitHubService

    init {
        mView.presenter = this
    }

    override fun start() {
        mService = mRepository.createService()
    }

    override fun request() {
        mService.listRepos(mView.getUser())
                .subscribeOn(Schedulers.newThread())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe({
                    if (it.isEmpty()) {
                        mView.showError()
                        return@subscribe
                    }
                    mView.hideButton()
                    mView.showList(it)
                }, {
                    mView.showError()
                })
    }
}

分離後のActivity

今までonCreate()に全てのロジックを書いていたところが分離後はpresenterのインスタンス化、presenter.start()、ボタンのクリックリスナーでpresenter.request()を呼び出すだけになりました。
また元々privateで定義していたメソッドはViewのinterfaceを継承しているのでoverrideをつけただけです。

MainActivity.kt
class MainActivity : AppCompatActivity(), GitHubContract.View {

    override lateinit var presenter: GitHubContract.Presenter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        presenter = GitHubPresenter(GitHubRepositoryImpl(this), this, SchedulerProvider)
        presenter.start()

        button.setOnClickListener({
            presenter.request(getString(R.string.user))
        })
    }

    override fun hideButton() {
        button.visibility = View.GONE
    }

    override fun showError() {
        Toast.makeText(this, getString(R.string.error_message), Toast.LENGTH_LONG).show()
    }

    override fun showList(it: List<RepoEntity>) {
        var list = listOf<String>()
        it.forEach { list += it.name }
        val adapter = ArrayAdapter<String>(this, android.R.layout.simple_list_item_1, list)
        listView.adapter = adapter
        listView.visibility = View.VISIBLE
    }
}

Presenterをテストできるようにする

このままだとRxのところがテストできないのでSchedulerを差し替えれるようにします。

Schedulerのinterface

BaseSchedulerProvider.kt
interface BaseSchedulerProvider {
    fun computation(): Scheduler
    fun io(): Scheduler
    fun ui(): Scheduler
}

実装側で使うScheduler

SchedulerProvider.kt
object SchedulerProvider : BaseSchedulerProvider {
    override fun computation(): Scheduler = Schedulers.computation()
    override fun io(): Scheduler = Schedulers.io()
    override fun ui(): Scheduler = AndroidSchedulers.mainThread()
}

テストで使うScheduler

ImmediateSchedulerProvider.kt
class ImmediateSchedulerProvider : BaseSchedulerProvider {
    override fun computation(): Scheduler = Schedulers.trampoline()
    override fun io(): Scheduler = Schedulers.trampoline()
    override fun ui(): Scheduler = Schedulers.trampoline()
}

PresenterのコンストラクタにBaseSchedulerProviderを指定して、RxのsubscribeOnobserveOnに実装側のSchedulerを指定します。

GitHubPresenter.kt
class GitHubPresenter(
        private val mRepository: GitHubRepository,
        private val mView: GitHubContract.View,
        private val mSchedulerProvider: BaseSchedulerProvider) : GitHubContract.Presenter {

    // 省略

    override fun request() {
        mService.listRepos(mView.getUser())
                .subscribeOn(mSchedulerProvider.io())
                .observeOn(mSchedulerProvider.ui())
                .subscribe({
                    if (it.isEmpty()) {
                        mView.showError()
                        return@subscribe
                    }

                    mView.hideButton()
                    mView.showList(it)
                }, {
                    mView.showError()
                })
    }
}

Presenterのコンストラクタ引数を追加したので、MainActivityのPresenterのインスタンスを作るところで実装側のSchedulerを指定しておきます。

MainActivity.kt
presenter = GitHubPresenter(GitHubRepositoryImpl(this), this, SchedulerProvider)

詳しくは【Android】RxJavaの非同期処理部分をテストするを参照してください。

テストを書く

上記の工程でPresenterがAndroid非依存でテスタブルなクラスになったのでテストを書いていきます。

Mockitoを使ってRepositoryとViewをモックします。
また事前に用意したテスト用Scheduler(ImmediateSchedulerProvider)を使ってPresenterをインスタンス化します。

GitHubPresenterTest.kt
class GitHubPresenterTest {
    @Mock private lateinit var mRepository: GitHubRepository
    @Mock private lateinit var mView: GitHubContract.View

    private lateinit var mPresenter: GitHubPresenter
    private lateinit var mSchedulerProvider: BaseSchedulerProvider

    companion object {
        const val USER = "test_user"
    }

    @Before
    fun setUp() {
        MockitoAnnotations.initMocks(this)
        mSchedulerProvider = ImmediateSchedulerProvider()
        mPresenter = GitHubPresenter(mRepository, mView, mSchedulerProvider)
    }

    @Test
    fun start() {
        mPresenter.start()
        verify(mRepository).initService()
    }

    @Test
    fun requestError() {
        `when`(request()).thenReturn(Observable.error(Exception()))
        mPresenter.request(USER)
        verify(mView).showError()
    }

    @Test
    fun requestEmpty() {
        `when`(request()).thenReturn(Observable.just(listOf()))
        mPresenter.request(USER)
        verify(mView).showError()
    }

    @Test
    fun requestSuccess() {
        val list = listOf(RepoEntity("name"))
        `when`(request()).thenReturn(Observable.just(list))
        mPresenter.request(USER)
        verify(mView).showList(list)
        verify(mView).hideButton()
    }

    private fun request() = mRepository.request(USER)

}

アーキテクチャ

全体のアーキテクチャは以下のようになりました。

パッケージ構成

パッケージ構成はとりあえず以下のようになりました。
サンプルなので簡易的な構成にしましたが、画面が増える毎にActivityやPresenterなどが増えていくので更に階層分けしたほうがいいでしょう。

$ tree app/src/main/java/rkowase/mvpsample/
app/src/main/java/rkowase/mvpsample/
├── data
│   ├── entity
│   │   └── RepoEntity.kt
│   ├── repository
│   │   ├── GitHubRepository.kt
│   │   └── GitHubRepositoryImpl.kt
│   └── service
│       └── GitHubService.kt
├── presentation
│   ├── BasePresenter.kt
│   ├── GitHubContract.kt
│   └── GitHubPresenter.kt
├── ui
│   ├── BaseView.kt
│   └── MainActivity.kt
└── util
    └── scheduler
        ├── BaseSchedulerProvider.kt
        ├── ImmediateSchedulerProvider.kt
        └── SchedulerProvider.kt

8 directories, 12 files

次のステップ

テストできるようになったら次のステップとしては以下があります。

  • Domain層追加(Clean ArchitectureでいうUse Caseを置く層)
    • Viewに関わらないビジネスロジックはUse Caseにしたほうがよさそうです。
    • 今回の例だとAPIのレスポンスからListView表示用Adapterに渡すListに変換する処理をMapperとして切り分けてテストを書くこともできます。
  • Repository層をRemoteとLocalに分割
    • APIのレスポンスをキャッシュしたりSQLiteから取得するためにRemoteとLocalを分けることもできます。
  • DIライブラリ(DaggerKodeinなど)導入
    • DIライブラリを使うとコンストラクタインジェクションなどがシュッとできます。
  • AAC(Android Architecture Component)導入
    • AACのLifecycleOwnerやLiveDataを使うと画面回転によるライフサイクルの取扱や変更通知の仕組みを楽に実装できます。
  • 各層をモジュール化
    • モジュール分割してGradleのdependenciesに依存関係を明記することで各モジュールの責務を明確化し制約をつけることができます。
  • JUnit以外のテストフレームワーク(Spekkotlintest)を試してみる
    • テストが増えてくるとJUnitの記法だと少し冗長になる場合もあるので、他のテストフレームワークを検討してみてもいいかもしれません。

まとめ

  • 各レイヤーをInterfaceで区切る
  • ロジック部分をピュアJava/Kotlinにする
  • Mockitoを使ってオブジェクトをモックする
  • テスト時はSchedulerをSchedulers.trampoline()に差し替える

補足

  • 今回は第一歩かつあくまでテストがないところにテストをいれることを目的としたためDaggerやAACやマルチモジュールのことまでは踏み込んでいません。
  • もしDaggerやAAC未導入のプロダクトでメンバーが未経験の場合は、まずこの記事で紹介したように最小限のテストを書いてからDaggerやAACを入れたりTDDを試してみたりするのがいいかと思います。
  • また、これらをやる前にそもそもアーキテクチャを変える必要性やテストをする必要性を正しく理解・共有した上で進めていくのがいいかと思います。

発表スライド

2017/12/08のshibuya.apkで発表したスライドはこちらです。(ご参考まで)

既存コードをAndroid非依存なクラスに抽象化してユニットテストするための第一歩 // Speaker Deck
https://speakerdeck.com/rkowase/ji-cun-kotowoandroidfei-yi-cun-nakurasunichou-xiang-hua-siteyunitutotesutosurutamefalsedi-bu

参考

googlesamples/android-architecture: A collection of samples to discuss and showcase different architectural tools and patterns for Android apps.
https://github.com/googlesamples/android-architecture

bufferapp/android-clean-architecture-boilerplate: An android boilerplate project using clean architecture
https://github.com/bufferapp/android-clean-architecture-boilerplate