みんからきりまで

きりみんです。

MVP + DataBinding + KotlinとそのTestのサンプル書いた

定期設計妄想エントリです。

あらすじ

以前、
MVPっぽい設計でAndroidのUIロジックにテストを書くのを試してみた - みんからきりまで
で、CleanArchitectureを参考にMVPでテストコード書くみたいなサンプル書いたけど、Viewのメソッド細かくわけたり各クラスをinterfaceにしたりするの面倒くさすぎてあんまり実用的じゃないなみたいな感じになった。

そのあと、
PresenterとMockitoで今度こそAndroidのUIロジックにテストが書きたい!! - みんからきりまで
で、もうちょっと実装コストと書けるテストの有用性を考えて簡略化したものを書いたりした。

でもやっぱり面倒くさいので、DataBindingを使ってもっと楽に小さなアプリでも適用出来る感じにならないか考えて新しいサンプル書いた。

概要

基本的にはCleanArchitectureを参考にしたMVPというのは変わってなくて、色々な部分を省いてる。
1画面に対応するクラスの構成は以下のような感じになる。

Viewlayout
↓   ↓
PresenterViewModel

Repository

この内、Presenterに対してテストを書くことになり、ViewとRepositoryはMock化される。
ロジックは基本的にPresenterとPresenterから呼ばれるModelやUtilクラスに集約され、ViewとRepositoryには出来るだけロジックの入る余地がないようにする。
単純なViewへの値の反映は出来るだけDataBindingの仕組みを利用するが、表出判定などのViewロジックはPresenterが担う。

DataBindingを利用するメリットとしては、PresenterからViewに対して更新リクエストを投げるためにViewのInterfaceに単調なメソッドをたくさん定義する必要がなくなる事と、Viewの状態をPresenterが持っているためよりViewロジックのテストが書きやすいという事などがある。

サンプルコード

文章で説明してもイメージが湧かないと思うので、例によってここからは実際のコードを貼り付けていく。
題材は以前書いたGithubのユーザープロフィールを表示するだけのアプリ。

https://i.gyazo.com/5bdbbfd02acf04017631631ad50b8462.png

コードの全体はこのリポジトリで確認出来ます。

GitHub - kirimin/WhoOnGitHub at mvpvm

View

interface UserInfoView {

    fun initActionBar(id: String)

    fun addLanguage(language: LanguageVM)

    fun addRepository(repository: RepositoryVM)

    fun networkErrorHandling()

    class UserInfoActivity : AppCompatActivity(), UserInfoView {

        companion object {

            private val EXTRA_ID = "id"

            fun buildBundle(id: String): Bundle {
                val bundle = Bundle()
                bundle.putString(EXTRA_ID, id)
                return bundle
            }
        }

        private lateinit var presenter: UserInfoPresenter

        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            presenter = UserInfoPresenter(this, UserInfoRepository())
            val binding = DataBindingUtil.setContentView<ActivityUserInfoBinding>(this, R.layout.activity_user_info)
            binding.presenter = presenter
            presenter.onCreate(intent.getStringExtra(EXTRA_ID))
        }

        override fun onDestroy() {
            presenter.onDestroy()
            super<AppCompatActivity>.onDestroy()
        }

        override fun onOptionsItemSelected(item: MenuItem): Boolean {
            if (android.R.id.home == item.itemId) {
                finish()
            }
            return super<AppCompatActivity>.onOptionsItemSelected(item)
        }

        override fun initActionBar(id: String) {
            setSupportActionBar(toolbar)
            val actionBar = supportActionBar ?: return
            actionBar.setDisplayHomeAsUpEnabled(true)
            actionBar.setHomeButtonEnabled(true)
        }

        override fun addLanguage(language: LanguageVM) {
            val binding = DataBindingUtil.inflate<ViewLanguageBinding>(LayoutInflater.from(this), R.layout.view_language, languageLayout, true)
            binding.language = language
        }

        override fun addRepository(repository: RepositoryVM) {
            val binding = DataBindingUtil.inflate<ViewRepositoryBinding>(LayoutInflater.from(this), R.layout.view_repository, repositoryLayout, true)
            binding.repository = repository
            binding.userInfoRepositoryBrowser.setOnClickListener {
                startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(repository.htmlUrl)))
            }
        }

        override fun networkErrorHandling() {
            Toast.makeText(applicationContext, "Error. Username does not exist?", Toast.LENGTH_SHORT).show()
            finish()
        }
    }

}

Presenter

class UserInfoPresenter(val view: UserInfoView, val repository: UserInfoRepository) {

    val subscriptions = CompositeSubscription()

    var user = ObservableField<User>()
    var layoutVisibility = ObservableInt(View.INVISIBLE)
    var locationVisibility = ObservableInt(View.INVISIBLE)
    var companyVisibility = ObservableInt(View.INVISIBLE)
    var linkVisibility = ObservableInt(View.INVISIBLE)
    var mailVisibility = ObservableInt(View.INVISIBLE)
    var avatarVisibility = ObservableInt(View.INVISIBLE)

    fun onCreate(id: String) {
        view.initActionBar(id)
        val userRequest = repository.requestUser(id)
        val repositoriesRequest = getRepositoriesRequest(id)
        val userInfoRequest = Single.zip(userRequest, repositoriesRequest) { user, repositories -> user to repositories }
        subscriptions.add(userInfoRequest.subscribe({ pair ->
            user.set(pair.first)
            val repositories = pair.second
            layoutVisibility.set(View.VISIBLE)
            user.get().location?.let {
                locationVisibility.set(View.VISIBLE)
            }
            user.get().company?.let {
                companyVisibility.set(View.VISIBLE)
            }
            user.get().blog?.let {
                linkVisibility.set(View.VISIBLE)
            }
            user.get().email?.let {
                mailVisibility.set(View.VISIBLE)
            }
            user.get().avatar_url?.let {
                avatarVisibility.set(View.VISIBLE)
            }
            getLanguages(repositories).forEach {
                val language = LanguageVM(languageName = it.first,
                        languageCount = it.second.count().toString(),
                        languageStartCount = it.second.map { repo -> repo.stargazers_count }.sum().toString())
                view.addLanguage(language)
            }
            sortRepositories(repositories).forEach {
                val repoIcon = if (it.fork) R.drawable.ic_call_split_grey600_36dp else android.R.drawable.ic_menu_sort_by_size
                val repository = RepositoryVM(iconImageResource = repoIcon,
                        repositoryName = it.name,
                        starCount = it.stargazers_count.toString(),
                        language = it.language ?: "",
                        description = it.description,
                        htmlUrl = it.html_url ?: "")
                view.addRepository(repository)
            }
        }, { e ->
            if (BuildConfig.DEBUG) {
                e.printStackTrace()
            }
            view.networkErrorHandling()
        }))
    }

    fun onDestroy() {
        subscriptions.unsubscribe()
    }

    /**
     * リポジトリを言語ごとに言語名とリポジトリリストのPairにまとめたListを返す
     */
    fun getLanguages(repositories: List<Repository>): List<Pair<String, List<Repository>>> {
        return repositories
                .filter { it.language != null }
                .groupBy { it.language!! }
                .toList().sortedByDescending { language -> language.second.count() }
    }

    /**
     * リポジトリをスターの数とforkか否かでソートして返す
     */
    fun sortRepositories(repositories: List<Repository>): List<Repository> {
        return repositories
                .sortedByDescending { repo -> repo.stargazers_count }
                .sortedBy { repo -> repo.fork }
    }

    /**
     * APIの全ページをリクエストしリポジトリを全て取得するSingleを返す
     */
    fun getRepositoriesRequest(id: String): Single<List<Repository>> {
        fun getAllRepos(page: Int): Observable<List<Repository>> {
            return repository.requestRepository(id, page)
                    .flatMap { if (it.size == 0) Observable.just(it) else Observable.merge(Observable.just(it), getAllRepos(page + 1)) }
        }
        return getAllRepos(1)
                .toList()
                .map { it.flatMap { it } }
                .toSingle()
    }
}

Repository

open class UserInfoRepository {

    open fun requestUser(id: String): Single<User> {
        return RetrofitClient.default().build().create(GitHubService::class.java).user(id)
                .subscribeOn(Schedulers.newThread())
                .observeOn(AndroidSchedulers.mainThread())
    }

    open fun requestRepository(id: String, page: Int): Observable<List<Repository>> {
        val retrofit = RetrofitClient.default().build().create(GitHubService::class.java)
        return retrofit.repositories(id = id, page = page)
                .subscribeOn(Schedulers.newThread())
                .observeOn(AndroidSchedulers.mainThread())
    }
}

Test

Presenterに対するテストのイメージ。
このテストはAndroidTestやRobolectricを使用せずに実行出来る。

class UserInfoPresenterTest {

    lateinit var viewMock: UserInfoView
    lateinit var repositoryMock: UserInfoRepository
    lateinit var presenter: UserInfoPresenter

    @Before
    fun setup() {
        viewMock = mock()
        repositoryMock = mock()
        presenter = UserInfoPresenter(viewMock, repositoryMock)
    }

    @Test
    fun onCreateTest() {
        val user = User(login = "kirimin", location = "tokyo", company = null, blog = null, email = null, avatar_url = null)
        whenever(repositoryMock.requestUser("kirimin")).then { Single.just(user) }
        whenever(repositoryMock.requestRepository("kirimin", 1)).then { Observable.just(listOf<Repository>()) }
        presenter.onCreate("kirimin")
        verify(repositoryMock, times(1)).requestUser("kirimin")
        verify(repositoryMock, times(1)).requestRepository("kirimin", 1)
        verify(viewMock, times(1)).initActionBar("kirimin")
        Assert.assertEquals(presenter.layoutVisibility.get(), View.VISIBLE)
        Assert.assertEquals(presenter.user.get(), user)
    }

    @Test
    fun networkErrorTest() {
        whenever(repositoryMock.requestUser("kirimin")).then { Single.error<User>(Throwable()) }
        whenever(repositoryMock.requestRepository("kirimin", 1)).then { Observable.just(listOf<Repository>()) }
        presenter.onCreate("kirimin")
        verify(viewMock, times(1)).networkErrorHandling()
    }

    @Test
    fun onDestroyTest() {
        presenter.onDestroy()
        Assert.assertTrue(presenter.subscriptions.isUnsubscribed)
    }

    @Test
    fun repositoriesTest() {
        val repositories = listOf<Repository>(
                Repository(name = "repo1", language = "Java", stargazers_count = 3),
                Repository(name = "repo2", language = "Kotlin", stargazers_count = 6),
                Repository(name = "repo3", language = "Java", stargazers_count = 1),
                Repository(name = "repo4", language = null, stargazers_count = 5, fork = true)
        )
        whenever(repositoryMock.requestUser("kirimin")).then { Single.just(User()) }
        whenever(repositoryMock.requestRepository("kirimin", 1)).then { Observable.just(repositories) }
        whenever(repositoryMock.requestRepository("kirimin", 2)).then { Observable.just(listOf<Repository>()) }
        presenter.onCreate("kirimin")
        verify(viewMock, times(1)).addLanguage(LanguageVM("Java", "2", "4"))
        verify(viewMock, times(1)).addLanguage(LanguageVM("Kotlin", "1", "6"))

        val repo1VM = RepositoryVM(language = "Java", description = "", htmlUrl = "",
                iconImageResource = android.R.drawable.ic_menu_sort_by_size,
                repositoryName = "repo1", starCount = "3")
        verify(viewMock, times(1)).addRepository(repo1VM)
        val repo2VM = RepositoryVM(language = "Kotlin", description = "", htmlUrl = "",
                iconImageResource = android.R.drawable.ic_menu_sort_by_size,
                repositoryName = "repo2", starCount = "6")
        verify(viewMock, times(1)).addRepository(repo2VM)
        val repo3VM = RepositoryVM(language = "Java", description = "", htmlUrl = "",
                iconImageResource = android.R.drawable.ic_menu_sort_by_size,
                repositoryName = "repo3", starCount = "1")
        verify(viewMock, times(1)).addRepository(repo3VM)
        val repo4VM = RepositoryVM(language = "", description = "", htmlUrl = "",
                iconImageResource = R.drawable.ic_call_split_grey600_36dp,
                repositoryName = "repo4", starCount = "5")
        verify(viewMock, times(1)).addRepository(repo4VM)
    }

    @Test
    fun defaultVisibilityTest() {
        Assert.assertEquals(presenter.layoutVisibility.get(), View.INVISIBLE)
        Assert.assertEquals(presenter.locationVisibility.get(), View.INVISIBLE)
        Assert.assertEquals(presenter.companyVisibility.get(), View.INVISIBLE)
        Assert.assertEquals(presenter.linkVisibility.get(), View.INVISIBLE)
        Assert.assertEquals(presenter.mailVisibility.get(), View.INVISIBLE)
    }

    @Test
    fun showLocationTest() {
        whenever(repositoryMock.requestUser("kirimin")).then { Single.just(User(location = "tokyo", company = null, blog = null, email = null, avatar_url = null)) }
        whenever(repositoryMock.requestRepository("kirimin", 1)).then { Observable.just(listOf<Repository>()) }
        presenter.onCreate("kirimin")
        Assert.assertEquals(presenter.locationVisibility.get(), View.VISIBLE)
        Assert.assertEquals(presenter.companyVisibility.get(), View.INVISIBLE)
        Assert.assertEquals(presenter.linkVisibility.get(), View.INVISIBLE)
        Assert.assertEquals(presenter.mailVisibility.get(), View.INVISIBLE)
    }

    @Test
    fun showCompanyTest() {
        whenever(repositoryMock.requestUser("kirimin")).then { Single.just(User(location = null, company = "company", blog = null, email = null, avatar_url = null)) }
        whenever(repositoryMock.requestRepository("kirimin", 1)).then { Observable.just(listOf<Repository>()) }
        presenter.onCreate("kirimin")
        Assert.assertEquals(presenter.locationVisibility.get(), View.INVISIBLE)
        Assert.assertEquals(presenter.companyVisibility.get(), View.VISIBLE)
        Assert.assertEquals(presenter.linkVisibility.get(), View.INVISIBLE)
        Assert.assertEquals(presenter.mailVisibility.get(), View.INVISIBLE)
    }

    @Test
    fun showLinkTest() {
        whenever(repositoryMock.requestUser("kirimin")).then { Single.just(User(location = null, company = null, blog = "http://kirimin.me", email = null, avatar_url = null)) }
        whenever(repositoryMock.requestRepository("kirimin", 1)).then { Observable.just(listOf<Repository>()) }
        presenter.onCreate("kirimin")
        Assert.assertEquals(presenter.locationVisibility.get(), View.INVISIBLE)
        Assert.assertEquals(presenter.companyVisibility.get(), View.INVISIBLE)
        Assert.assertEquals(presenter.linkVisibility.get(), View.VISIBLE)
        Assert.assertEquals(presenter.mailVisibility.get(), View.INVISIBLE)
    }

    @Test
    fun showMailTest() {
        whenever(repositoryMock.requestUser("kirimin")).then { Single.just(User(location = null, company = null, blog = null, email = "cc@kirimin.me", avatar_url = null)) }
        whenever(repositoryMock.requestRepository("kirimin", 1)).then { Observable.just(listOf<Repository>()) }
        presenter.onCreate("kirimin")
        Assert.assertEquals(presenter.locationVisibility.get(), View.INVISIBLE)
        Assert.assertEquals(presenter.companyVisibility.get(), View.INVISIBLE)
        Assert.assertEquals(presenter.linkVisibility.get(), View.INVISIBLE)
        Assert.assertEquals(presenter.mailVisibility.get(), View.VISIBLE)
    }

    @Test
    fun getRepositoriesRequestTest() {
        val testSubscriber = TestSubscriber<List<Repository>>()
        whenever(repositoryMock.requestRepository("kirimin", 1)).then { Observable.just(listOf<Repository>(Repository(name = "1"))) }
        whenever(repositoryMock.requestRepository("kirimin", 2)).then { Observable.just(listOf<Repository>(Repository(name = "2"))) }
        whenever(repositoryMock.requestRepository("kirimin", 3)).then { Observable.just(listOf<Repository>()) }
        presenter.getRepositoriesRequest("kirimin").subscribe(testSubscriber)
        val result = testSubscriber.onNextEvents[0]
        verify(repositoryMock, times(1)).requestRepository("kirimin", 1)
        verify(repositoryMock, times(1)).requestRepository("kirimin", 2)
        verify(repositoryMock, times(1)).requestRepository("kirimin", 3)
        Assert.assertEquals(result.size, 2)
        Assert.assertEquals(result[0].name, "1")
        Assert.assertEquals(result[1].name, "2")
    }

    @Test
    fun getRepositoriesRequestEmptyTest() {
        val testSubscriber = TestSubscriber<List<Repository>>()
        whenever(repositoryMock.requestRepository("kirimin", 1)).then { Observable.just(listOf<Repository>()) }
        presenter.getRepositoriesRequest("kirimin").subscribe(testSubscriber)
        val result = testSubscriber.onNextEvents[0]
        verify(repositoryMock, times(1)).requestRepository("kirimin", 1)
        verify(repositoryMock, never()).requestRepository("kirimin", 2)
        Assert.assertEquals(result.size, 0)
    }
}