定期設計妄想エントリです。
あらすじ
以前、
MVPっぽい設計でAndroidのUIロジックにテストを書くのを試してみた - みんからきりまで
で、CleanArchitectureを参考にMVPでテストコード書くみたいなサンプル書いたけど、Viewのメソッド細かくわけたり各クラスをinterfaceにしたりするの面倒くさすぎてあんまり実用的じゃないなみたいな感じになった。
そのあと、
PresenterとMockitoで今度こそAndroidのUIロジックにテストが書きたい!! - みんからきりまで
で、もうちょっと実装コストと書けるテストの有用性を考えて簡略化したものを書いたりした。
でもやっぱり面倒くさいので、DataBindingを使ってもっと楽に小さなアプリでも適用出来る感じにならないか考えて新しいサンプル書いた。
概要
基本的にはCleanArchitectureを参考にしたMVPというのは変わってなくて、色々な部分を省いてる。
1画面に対応するクラスの構成は以下のような感じになる。
View→layout
↓ ↓
Presenter→ViewModel
↓
Repository
この内、Presenterに対してテストを書くことになり、ViewとRepositoryはMock化される。
ロジックは基本的にPresenterとPresenterから呼ばれるModelやUtilクラスに集約され、ViewとRepositoryには出来るだけロジックの入る余地がないようにする。
単純なViewへの値の反映は出来るだけDataBindingの仕組みを利用するが、表出判定などのViewロジックはPresenterが担う。
DataBindingを利用するメリットとしては、PresenterからViewに対して更新リクエストを投げるためにViewのInterfaceに単調なメソッドをたくさん定義する必要がなくなる事と、Viewの状態をPresenterが持っているためよりViewロジックのテストが書きやすいという事などがある。
サンプルコード
文章で説明してもイメージが湧かないと思うので、例によってここからは実際のコードを貼り付けていく。
題材は以前書いたGithubのユーザープロフィールを表示するだけのアプリ。
コードの全体はこのリポジトリで確認出来ます。
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) } }