最近Android界隈ではMVPという言葉をよく聞く気がします。
個人的にも気になっていて、特に「テストが書きやすくなる」という部分がとても気になります。
ところがどうもテストが書かれたよさげなサンプルコードがなかなか見つからない…。
そこで、全然テストが書けていない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ロジックに対して意味のあるレベルのテストを書くイメージが見えた気がします。
この粒度でテストが書けていれば、条件による値の渡し間違いやメソッドの呼び忘れといったデグレは検知する事が出来そうです。
ただし、この粒度でActivityにメソッドを作ってInterfaceを定義してテストを書いて…とやっていくのは正直かなり冗長というか精神的にも工数的にも辛そう。
ここまで細かくしなくてもいい気もするけど、Activityのメソッド粒度を荒くしてしまうとActivityにUIロジックが入り込んでしまい、Presenterのテストがあまり意味がなくなってしまうし…。
今回は取得したデータを単純に一覧表示するだけのサンプルだったから余計に不毛っぽさが出てしまったのかもしれない。
この辺りはテストライブラリや設計の工夫などでもう少しどうにか出来るのだろうか。
もうちょっと色々試して工夫してみたい。