みんからきりまで

きりみんです。

MVPっぽい設計でAndroidのUIロジックにテストを書くのを試してみた

最近Android界隈ではMVPという言葉をよく聞く気がします。

konifar.hatenablog.com

tech.recruit-mp.co.jp

個人的にも気になっていて、特に「テストが書きやすくなる」という部分がとても気になります。
ところがどうもテストが書かれたよさげなサンプルコードがなかなか見つからない…。

そこで、全然テストが書けていないAndroid開発者の一人として、本当にUIロジックのテストが可能なものなのか、実際にMVPっぽいものを書いて試してみました。
ちなみに僕はユニットテストの知見が一切ありません。

MVP

Model View Presenterの事です。
詳細は僕もまだそこまでちゃんと理解出来ていないので、上記の記事などを参照してください。
ざっくり言うと、Controllerの代わりにPresenterというもの作り、PresenterがUIロジックを担う事でViewなのかControllerなのか扱いが曖昧だったActivityやFragmentを完全に値を受け渡すだけのViewとして扱い取り替え可能なものにするような感じのようです。
上記の記事ではそこにDDDでの設計パターンを取り入れ、UseCaseなど定義しているようです。

書いてみた

以前Kotlinのサンプルで作ったGithubのユーザー情報を表示するだけのアプリを書き換えてみました。
github.com

あ、もちろんKotlinです。

クラス概要

Presenterを作りテストを書くというのを目的として書きました。

  • UserInfoActivity:ViewにあたるActivity。
  • IUserInfoActivity:UserInfoActivityのInterface。
  • UserInfoPresenter:ActivityとUseCaseを持ちUIロジックを担う。
  • UserInfoUseCase:データを取得するビジネスロジックを担う。
  • IUserInfoUseCase:UserInfoUseCaseのInterface。

Activity

Activityには表出判定などのUIロジックは書かず、Viewに値を渡し、Presenterにイベントを渡すだけの存在です。

public class UserInfoActivity : AppCompatActivity(), IUserInfoActivity {

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

    var presenter: UserInfoPresenter? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super<AppCompatActivity>.onCreate(savedInstanceState)
        setContentView(R.layout.activity_user_info)

        presenter = UserInfoPresenter(this, UserInfoUseCase())
        presenter!!.onCreate(RequestQueueSingleton.get(getApplicationContext()))
    }

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

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

    override public fun initActionBar(id: String) {
        val actionBar = getSupportActionBar()
        actionBar.setDisplayHomeAsUpEnabled(true)
        actionBar.setHomeButtonEnabled(true)
        actionBar.setTitle(id)
    }

    override public fun setParentLayoutVisibility(visibility: Int){
        parentLayout.setVisibility(visibility)
    }

    override public fun getIdFromBundle(): String {
        return getIntent().getStringExtra("id")
    }

    override public fun setName(name: String) {
        nameText.setText(name)
    }

    override public fun setId(id: String) {
        idText.setText(id)
    }

    override public fun setLocation(location: String) {
        locationText.setText(location)
    }

    override public fun setLocationVisibility(visibility: Int) {
        locationText.setVisibility(visibility)
    }

    override public fun setCompany(company: String) {
        companyText.setText(company)
    }

    override public fun setCompanyVisibility(visibility: Int) {
        companyText.setVisibility(visibility)
    }

    override public fun setLink(link: String) {
        linkText.setText(link)
    }

    override public fun setLinkVisibility(visibility: Int) {
        linkText.setVisibility(visibility)
    }

    override public fun setMail(mail: String) {
        mailText.setText(mail)
    }

    override public fun setMailVisibility(visibility: Int) {
        mailText.setVisibility(visibility)
    }

    override public fun setIcon(url: String) {
        Picasso.with(this).load(url).fit().into(iconImage)
    }

    override public fun addLanguage(languageName: String, languageCount: String, languageStartCount: String) {
        val inflater = LayoutInflater.from(this);
        val languageView = inflater.inflate(R.layout.activity_user_info_language, null) as LinearLayout
        val languageNameText = languageView.findViewById(R.id.userInfoLanguageName) as TextView
        val languageCountText = languageView.findViewById(R.id.userInfoLanguageCount) as TextView
        val languageStartCountText = languageView.findViewById(R.id.userInfoLanguageStarCount) as TextView
        languageNameText.setText(languageName)
        languageCountText.setText(languageCount)
        languageStartCountText.setText(languageStartCount)
        languageLayout.addView(languageView)
    }

    override public fun addRepository(iconImageResource: Int, repositoryName: String, starCount: String, language: String, description: String, htmlUrl: String) {
        val inflater = LayoutInflater.from(this);
        val repositoryView = inflater.inflate(R.layout.activity_user_info_repositories, null) as LinearLayout
        val repositoryIconImage = repositoryView.findViewById(R.id.userInfoRepositoryIcon) as ImageView
        val repositoryNameText = repositoryView.findViewById(R.id.userInfoRepositoryName) as TextView
        val repositoryDescriptionText = repositoryView.findViewById(R.id.userInfoRepositoryDescription) as TextView
        val repositoryStarCountText = repositoryView.findViewById(R.id.userInfoRepositoryStarCount) as TextView
        val repositoryLanguageText = repositoryView.findViewById(R.id.userInfoRepositoryLanguage) as TextView
        repositoryIconImage.setImageResource(iconImageResource)
        repositoryNameText.setText(repositoryName)
        repositoryStarCountText.setText(starCount)
        repositoryLanguageText.setText(language)
        repositoryDescriptionText.setText(description)
        repositoryView.findViewById(R.id.userInfoRepositoryBrowser).setOnClickListener {
            startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(htmlUrl)))
        }
        repositoryLayout.addView(repositoryView)
    }

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

そして、メソッドは全てInterfaceに定義します。

interface IUserInfoActivity {

    public fun initActionBar(id: String)

    public fun getIdFromBundle(): String

    public fun setParentLayoutVisibility(visibility: Int)

    public fun setName(name: String)

    public fun setId(id: String)

    public fun setLocation(location: String)

    public fun setLocationVisibility(visibility: Int)

    public fun setCompany(company: String)

    public fun setCompanyVisibility(visibility: Int)

    public fun setLink(link: String)

    public fun setLinkVisibility(visibility: Int)

    public fun setMail(mail: String)

    public fun setMailVisibility(visibility: Int)

    public fun setIcon(url: String)

    public fun addLanguage(languageName: String, languageCount: String, languageStartCount: String)

    public fun addRepository(iconImageResource: Int, repositoryName: String, starCount: String, language: String, description: String, htmlUrl: String)

    public fun networkErrorHandling()
}

Presenter

PresenterはActivityとUseCaseのInterfaceを持ち、Activityのメソッドを呼び出す事でUIロジックを記述します。

public class UserInfoPresenter(val view: IUserInfoActivity, val useCase: IUserInfoUseCase) {

    private val subscriptions = CompositeSubscription();

    public fun onCreate(queue: RequestQueue) {
        val id = view.getIdFromBundle()
        view.initActionBar(id)
        useCase.requestUserInfo ({ pair ->
            val user = pair.first
            val repositories = pair.second
            view.setParentLayoutVisibility(View.VISIBLE)
            view.setName(user.name)
            view.setId(user.login)
            if (isDataEmpty(user.location)) {
                view.setLocationVisibility(View.GONE)
            } else {
                view.setLocation(user.location)
                view.setLocationVisibility(View.VISIBLE)
            }
            if (isDataEmpty(user.company)) {
                view.setCompanyVisibility(View.GONE)
            } else {
                view.setCompany(user.company)
                view.setCompanyVisibility(View.VISIBLE)
            }
            if (isDataEmpty(user.blog)) {
                view.setLinkVisibility(View.GONE)
            } else {
                view.setLink(user.blog)
                view.setLinkVisibility(View.VISIBLE)
            }
            if (isDataEmpty(user.email)) {
                view.setMailVisibility(View.GONE)
            } else {
                view.setMail(user.email)
                view.setMailVisibility(View.VISIBLE)
            }
            if (!isDataEmpty(user.avatarUrl)) {
                view.setIcon(user.avatarUrl)
            }

            useCase.getLanguages(repositories).forEach { language ->
                view.addLanguage(languageName = language.first,
                        languageCount = language.second.count().toString(),
                        languageStartCount = language.second.map { repo -> repo.stargazersCount }.sum().toString())
            }
            useCase.sortRepositories(repositories).forEach { repository ->
                val repoIcon = if (repository.fork) R.drawable.ic_call_split_grey600_36dp else android.R.drawable.ic_menu_sort_by_size
                view.addRepository(iconImageResource = repoIcon,
                        repositoryName = repository.name,
                        starCount = repository.stargazersCount.toString(),
                        language = if (isDataEmpty(repository.language)) "" else repository.language,
                        description = repository.description,
                        htmlUrl = repository.htmlUrl)
            }
        }, { e -> view.networkErrorHandling() }, id, queue, subscriptions)
    }

    public fun onDestroy() {
        subscriptions.unsubscribe()
    }

    private fun isDataEmpty(data: String): Boolean {
        return data.equals("null") || data.isEmpty()
    }
}

UseCase

UseCaseはPresenterから呼び出されるビジネスロジックです。
この場合はGithubのユーザー情報の取得操作ですが、実際の処理は更に具体的なロジッククラスのUserApiに移譲しています。
MVPに書き換える前はActivityが直接UserApiのメソッドを呼ぶ形でした。

public class UserInfoUseCase : IUserInfoUseCase {

    override fun requestUserInfo(onSuccess: (Pair<User, List<Repository>>) -> Unit, onError: (Throwable) -> Unit,
                                 id: String, queue: RequestQueue, subscriptions: CompositeSubscription) {
        val userRequest = UsersApi.request(id = id, requestQueue = queue)
        val repositoryRequest = RepositoryApi.request(id = id, page = 1, requestQueue = queue).toList()
        subscriptions.add(Observable
                .zip(userRequest, repositoryRequest, { user, repositories -> Pair(user, repositories) })
                .subscribeOn(Schedulers.newThread())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe({ response ->
                    onSuccess(response)
                }, { e ->
                    onError(e)
                }))
    }

    override fun getLanguages(repositories: List<Repository>): List<Pair<String, List<Repository>>> {
        return repositories
                .filter { repo -> !repo.language.equals("null") }
                .groupBy { repo -> repo.language }
                .toList().sortDescendingBy { language -> language.second.count() }
    }

    override fun sortRepositories(repositories: List<Repository>): List<Repository> {
        return repositories
                .sortDescendingBy { repo -> repo.stargazersCount }
                .sortBy { repo -> repo.fork }
    }
}

UIロジックにテストを書く

さて、ここからがようやく本題です。
PresenterのUIロジックにテストを書いてみます。

@RunWith(AndroidJUnit4::class)
public class UserInfoPresenterTest {

    Test
    public fun setNameTest() {
        var isSuccess = false
        val presenter = UserInfoPresenter(
                object : UserInfoActivityMock() {
                    override fun setName(name: String) {
                        assertThat(name, `is`("name"))
                        isSuccess = true
                    }
                }, object : UserInfoUseCaseMock(UserMockBuilder.defaultCase()) {})

        presenter.onCreate(RequestQueueSingleton.get(InstrumentationRegistry.getContext()))
        assertTrue(isSuccess)
    }

    Test
    public fun setIdTest() {
        var isSuccess = false
        val presenter = UserInfoPresenter(
                object : UserInfoActivityMock() {
                    override fun setId(id: String) {
                        assertThat(id, `is`("login"))
                        isSuccess = true
                    }

                }, object : UserInfoUseCaseMock(UserMockBuilder.defaultCase()) {})

        presenter.onCreate(RequestQueueSingleton.get(InstrumentationRegistry.getContext()))
        assertTrue(isSuccess)
    }

    Test
    public fun setLocationTest() {
        var isSuccess = false
        val presenter = UserInfoPresenter(
                object : UserInfoActivityMock() {
                    override fun setLocation(location: String) {
                        assertThat(location, `is`("location"))
                        isSuccess = true
                    }

                    override fun setLocationVisibility(visibility: Int) {
                        assertThat(visibility, `is`(View.VISIBLE))
                    }
                }, object : UserInfoUseCaseMock(UserMockBuilder.defaultCase()) {})

        presenter.onCreate(RequestQueueSingleton.get(InstrumentationRegistry.getContext()))
        assertTrue(isSuccess)
    }

    Test
    public fun locationNullCaseTest() {
        var isSuccess = false
        val presenter = UserInfoPresenter(
                object : UserInfoActivityMock() {
                    override fun setLocationVisibility(visibility: Int) {
                        assertThat(visibility, `is`(View.GONE))
                        isSuccess = true
                    }
                }, object : UserInfoUseCaseMock(UserMockBuilder.nullCase()) {})

        presenter.onCreate(RequestQueueSingleton.get(InstrumentationRegistry.getContext()))
        assertTrue(isSuccess)
    }

    Test
    public fun setCompanyTest() {
        var isSuccess = false
        val presenter = UserInfoPresenter(
                object : UserInfoActivityMock() {
                    override fun setCompany(company: String) {
                        assertThat(company, `is`("company"))
                        isSuccess = true
                    }

                    override fun setCompanyVisibility(visibility: Int) {
                        assertThat(visibility, `is`(View.VISIBLE))
                    }
                }, object : UserInfoUseCaseMock(UserMockBuilder.defaultCase()) {})

        presenter.onCreate(RequestQueueSingleton.get(InstrumentationRegistry.getContext()))
        assertTrue(isSuccess)
    }

    Test
    public fun companyNullCaseTest() {
        var isSuccess = false
        val presenter = UserInfoPresenter(
                object : UserInfoActivityMock() {
                    override fun setCompanyVisibility(visibility: Int) {
                        assertThat(visibility, `is`(View.GONE))
                        isSuccess = true
                    }
                }, object : UserInfoUseCaseMock(UserMockBuilder.nullCase()) {})

        presenter.onCreate(RequestQueueSingleton.get(InstrumentationRegistry.getContext()))
        assertTrue(isSuccess)
    }

    Test
    public fun setLinkTest() {
        var isSuccess = false
        val presenter = UserInfoPresenter(
                object : UserInfoActivityMock() {
                    override fun setLink(link: String) {
                        assertThat(link, `is`("blog"))
                        isSuccess = true
                    }

                    override fun setLinkVisibility(visibility: Int) {
                        assertThat(visibility, `is`(View.VISIBLE))
                    }
                }, object : UserInfoUseCaseMock(UserMockBuilder.defaultCase()) {})

        presenter.onCreate(RequestQueueSingleton.get(InstrumentationRegistry.getContext()))
        assertTrue(isSuccess)
    }

    Test
    public fun linkNullCaseTest() {
        var isSuccess = false
        val presenter = UserInfoPresenter(
                object : UserInfoActivityMock() {
                    override fun setLinkVisibility(visibility: Int) {
                        assertThat(visibility, `is`(View.GONE))
                        isSuccess = true
                    }
                }, object : UserInfoUseCaseMock(UserMockBuilder.nullCase()) {})

        presenter.onCreate(RequestQueueSingleton.get(InstrumentationRegistry.getContext()))
        assertTrue(isSuccess)
    }

    Test
    public fun setMailTest() {
        var isSuccess = false
        val presenter = UserInfoPresenter(
                object : UserInfoActivityMock() {
                    override fun setMail(mail: String) {
                        assertThat(mail, `is`("email"))
                        isSuccess = true
                    }

                    override fun setMailVisibility(visibility: Int) {
                        assertThat(visibility, `is`(View.VISIBLE))
                    }
                }, object : UserInfoUseCaseMock(UserMockBuilder.defaultCase()) {})

        presenter.onCreate(RequestQueueSingleton.get(InstrumentationRegistry.getContext()))
        assertTrue(isSuccess)
    }

    Test
    public fun mailNullCaseTest() {
        var isSuccess = false
        val presenter = UserInfoPresenter(
                object : UserInfoActivityMock() {
                    override fun setMailVisibility(visibility: Int) {
                        assertThat(visibility, `is`(View.GONE))
                        isSuccess = true
                    }
                }, object : UserInfoUseCaseMock(UserMockBuilder.nullCase()) {})

        presenter.onCreate(RequestQueueSingleton.get(InstrumentationRegistry.getContext()))
        assertTrue(isSuccess)
    }

    Test
    public fun setIconTest(){
        var isSuccess = false
        val presenter = UserInfoPresenter(
                object : UserInfoActivityMock() {
                    override fun setIcon(url: String) {
                        assertThat(url, `is`("avatar_url"))
                        isSuccess = true
                    }
                }, object : UserInfoUseCaseMock(UserMockBuilder.defaultCase()) {})

        presenter.onCreate(RequestQueueSingleton.get(InstrumentationRegistry.getContext()))
        assertTrue(isSuccess)
    }

    Test
    public fun iconNullCaseTest(){
        val presenter = UserInfoPresenter(
                object : UserInfoActivityMock() {
                    override fun setIcon(url: String) {
                        fail("")
                    }
                }, object : UserInfoUseCaseMock(UserMockBuilder.nullCase()) {})

        presenter.onCreate(RequestQueueSingleton.get(InstrumentationRegistry.getContext()))
    }
}

分かりにくいですが、PresenterにActivityとUseCaseのモック実装を渡し、ActivityのView操作メソッドが正しく呼び出されているかを検知する事でUIロジックをテストしています。
Activityのメソッドを細かく分け、Interfaceに定義したのはこのためです。

所感

勢いで一気に書き換えたので設計にはかなり再考の余地があると思いますが、初めてAndroidのUIロジックに対して意味のあるレベルのテストを書くイメージが見えた気がします。

f:id:kirimin:20150711213902p:plain

この粒度でテストが書けていれば、条件による値の渡し間違いやメソッドの呼び忘れといったデグレは検知する事が出来そうです。

ただし、この粒度でActivityにメソッドを作ってInterfaceを定義してテストを書いて…とやっていくのは正直かなり冗長というか精神的にも工数的にも辛そう。
ここまで細かくしなくてもいい気もするけど、Activityのメソッド粒度を荒くしてしまうとActivityにUIロジックが入り込んでしまい、Presenterのテストがあまり意味がなくなってしまうし…。
今回は取得したデータを単純に一覧表示するだけのサンプルだったから余計に不毛っぽさが出てしまったのかもしれない。

この辺りはテストライブラリや設計の工夫などでもう少しどうにか出来るのだろうか。
もうちょっと色々試して工夫してみたい。