- 最近、Androidの設計やらテストの書き方やらを試行錯誤していて、ちょっと情報が散らばってきたので個人的なまとめです。これが絶対的にイケてる!とかじゃなくて単にいろんな人のスライド読んだり、自分で試したりして今こんな感じになったというレベルのものです。
- コードは↓で作ったアプリをベースにいじってます。。Kotlin製のアプリなのでJavaに適時読み替えてください。
設計の話
設計の目標はモジュールの責務を分割して将来の変更に強くするという感じです。Androidの場合は特に適当に作るとActivityとFragmentが膨れ上がってメンテつらい作りになりがちです。で、去年に書いた記事(Androidのデザインパターンを考えてみたの続き。Kotlin対応版。 - もやもやエンジニア)とかを経て、いまのところはMVPな構成にしてModel層をDDDライクに作るようにしてます。先日のAndroidオールスターズ2でもid:kgmyshin 氏が同様の話をしてましたが、自分も近いものを目標に作ってます。
雑な絵だとこんな感じです。Activity/FragmentはViewの操作に集中して、ドメインロジックはModel層に、Presenterが両者を仲介するというふいんきですね。
実際に作った例
- 起動時にIDを設定する画面(InitializeFragment)を例にとってみます。この画面は入力されたIDが有効か問い合わせて有効なら端末に保存して次の画面に行くという仕様です。
- まずはPresenterとViewをつなぐInterfaceです。ViewはFragmentに実装してActionsはPresenterに実装させます。
interface InitializeContact { interface View { fun showNetworkErrorMessage() fun showProgress() fun hideProgress() fun displayInvalidUserIdMessage() fun navigateToMain() } interface Actions { fun onCreate(view: InitializeContact.View) fun onResume() fun onPause() fun onClickButtonSetId(userId: String) } }
- 次にFragmentです。ライフサイクルやユーザーの操作で発生したイベントをPresenterに伝えるのと、上で定義したInterfaceを実装してViewの操作をするメソッドを生やしてます。
- Presenterはテスト時に差し替えできるようにDagger経由で注入できるようにしておきます。Model層の操作はここでは一切出てきません。
class InitializeFragment() : BaseFragment(), InitializeContact.View, ProgressDialogController { companion object { fun newInstance(): InitializeFragment = InitializeFragment() } @Inject lateinit var navigator: ActivityNavigator @Inject lateinit var presenter: InitializeContact.Actions override var progressDialog: ProgressDialog? = null private var subscription: CompositeSubscription? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) component.inject(this) presenter.onCreate(this) } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { subscription = CompositeSubscription() val view = inflater.inflate(R.layout.fragment_initialize, container, false) val editId = view.findViewById(R.id.fragment_initialize_edit_hatena_id) as AppCompatEditText val buttonSetId = view.findViewById(R.id.fragment_initialize_button_set_hatena_id) as AppCompatButton buttonSetId.setOnClickListener { presenter.onClickButtonSetId(editId.editableText.toString()) } subscription?.add(RxTextView.textChanges(editId) .map { v -> 0 < v.length } .subscribe { isEnabled -> buttonSetId.isEnabled = isEnabled }) return view } override fun onResume() { super.onResume() presenter.onResume() } override fun onPause() { super.onPause() presenter.onPause() } override fun onDestroyView() { super.onDestroyView() subscription?.unsubscribe() subscription = null } override fun showNetworkErrorMessage() { with(activity as AppCompatActivity) { val rootView = findViewById(R.id.fragment_initialize_layout_root) hideKeyBoard(rootView) showSnackbarNetworkError(rootView) } } override fun showProgress() { showProgressDialog(activity) } override fun hideProgress() { closeProgressDialog() } override fun displayInvalidUserIdMessage() { view?.findViewById(R.id.fragment_initialize_layout_hatena_id)?.let { it as TextInputLayout it.error = getString(R.string.message_error_input_user_id) } } override fun navigateToMain() { navigator.navigateToMain(activity) activity.finish() } }
- 最後にView層から受け取ったイベントとModel層の操作を仲介するPresenterです。Presenterが依存するModel層のモジュールはコンストラクタで受け取るようにしています。テスト時にはモックにするなりして差し替えます。
class InitializePresenter(private val userRepository: UserRepository, private val userService: UserService) : InitializeContact.Actions { private lateinit var view: InitializeContact.View private var subscription: CompositeSubscription? = null private var isLoading = false override fun onCreate(view: InitializeContact.View) { this.view = view val userEntity = userRepository.resolve() if (userEntity.isCompleteSetting) { view.navigateToMain() } } override fun onResume() { subscription = CompositeSubscription() } override fun onPause() { subscription?.unsubscribe() subscription = null } override fun onClickButtonSetId(userId: String) { if (isLoading) return isLoading = true view.showProgress() subscription?.add(userService.confirmExistingUserId(userId) .doOnUnsubscribe { isLoading = false view.hideProgress() } .onBackpressureBuffer() .subscribeOn(Schedulers.newThread()) .observeOn(AndroidSchedulers.mainThread()) .subscribe({ onConfirmExistingUserIdSuccess(it, userId) }, { onConfirmExistingUserIdFailure(it) })) } private fun onConfirmExistingUserIdSuccess(isValid: Boolean, userId: String) { if (isValid) { userRepository.store(UserEntity(userId)) view.navigateToMain() } else { view.displayInvalidUserIdMessage() } } private fun onConfirmExistingUserIdFailure(e: Throwable) { if (e is HttpException) { if (e.code() == HttpURLConnection.HTTP_NOT_FOUND) { view.displayInvalidUserIdMessage() return } } view.showNetworkErrorMessage() } }
- これでFragmentはViewの操作のにのみ集中出来るようになって見通しがよくなりました。ViewはDatabindingを使うとよりスッキリするかと思います。
テストの話
Viewのテスト
- Viewは本来はEspressoを使ってエミュレーターなり実機なりの上でテストを動かす必要がありますが、Robolectric を使うと端末を使わずにテストができます。
- 事前にテスト用のアプリケーションクラスを作成してViewが依存するPresenterを差し替えておきます。全体のコードはGitHubを見た方が早いと思うので差し替えの部分だけ紹介。Test用のComponetを作ってファクトリメソッドをオーバーライドして差し替えます。
open class TestApp : App() { override fun createApplicationComponent(): ApplicationComponent { return DaggerTestApplicationComponent.builder() .applicationModule(ApplicationModule(this)) .infraLayerModule(InfraLayerModule()) .build() } }
- こちらが実際のRobolectricを使ったテストケースです。Configで上で作ったテスト用のアプリケーションを指定することで、テスト対象のFragmentが依存するPresenterを差し替えて、Model層の操作を行わないようにして、実装したViewのメソッドが正しく実装されているかのみを検証します。
- 余談ですがKotlinはMockitoのwhenやらhamcrestのisやらが予約語になってるのでバッククォートで囲む必要があってちょっとめんどくさい。。。
@RunWith(RobolectricTestRunner::class) @Config(constants = BuildConfig::class, application = TestApp::class, sdk = intArrayOf(Build.VERSION_CODES.LOLLIPOP)) class InitializeFragmentTest { lateinit var fragment: InitializeFragment private val view: View by lazy { fragment.view ?: throw IllegalStateException("fragment's view is Null") } private val editHatenaId: EditText get() = view.findViewById(R.id.fragment_initialize_edit_hatena_id) as EditText private val buttonSetHatenaId: Button get() = view.findViewById(R.id.fragment_initialize_button_set_hatena_id) as Button private val textInputLayoutHatenaId: TextInputLayout get() = view.findViewById(R.id.fragment_initialize_layout_hatena_id) as TextInputLayout private val snackbarTextView: TextView get() = fragment.activity.findViewById(android.support.design.R.id.snackbar_text) as TextView private fun getString(resId: Int): String { return fragment.getString(resId) } @Before fun setUp() { fragment = InitializeFragment.newInstance() SupportFragmentTestUtil.startFragment(fragment, SplashActivity::class.java) } @Test fun initialize() { assertThat(editHatenaId.visibility, `is`(View.VISIBLE)) assertThat(buttonSetHatenaId.visibility, `is`(View.VISIBLE)) assertThat(buttonSetHatenaId.isEnabled, `is`(false)) } @Test fun testButtonSetHatenaIdStatus_input_id() { editHatenaId.setText("a") assertThat(buttonSetHatenaId.isEnabled, `is`(true)) } @Test fun testButtonSetHatenaIdStatus_not_input_id() { editHatenaId.setText("") assertThat(buttonSetHatenaId.isEnabled, `is`(false)) } @Test fun testButtonSetHatenaIdClick() { val presenter = mock(InitializeContact.Actions::class.java) doAnswer { Unit }.`when`(presenter).onClickButtonSetId("valid") fragment.presenter = presenter editHatenaId.setText("valid") buttonSetHatenaId.performClick() verify(presenter).onClickButtonSetId("valid") } @Test fun testShowNetworkErrorMessage() { fragment.showNetworkErrorMessage() assertThat(snackbarTextView.visibility, `is`(View.VISIBLE)) assertThat(snackbarTextView.text.toString(), `is`(getString(R.string.message_error_network))) } @Test fun testDisplayInvalidUserIdMessage() { fragment.displayInvalidUserIdMessage() assertThat(textInputLayoutHatenaId.error.toString(), `is`(getString(R.string.message_error_input_user_id))) } @Test fun testShowHideProgress() { fragment.showProgress() assertThat(fragment.progressDialog?.isShowing, `is`(true)) fragment.hideProgress() assertNull(fragment.progressDialog) } @Test fun testNavigateToMain() { val navigator = spy(fragment.navigator) doAnswer { Unit }.`when`(navigator).navigateToMain(fragment.activity) fragment.navigator = navigator fragment.navigateToMain() verify(navigator).navigateToMain(fragment.activity) } }
Presenterのテスト
- PresenterはAndroidの実装に依存しない形になっているのでピュアなJUnitのテストになります。Contextが必要な場合などはMockitoを使ってContextのMockを作ってあげればいいかなと思います。
- setUpでモックのViewのメソッドを空実装するのと、RxAndroidのスケジューラーを使った処理がすぐ返るように設定しています。テストの検証はMockのViewのメソッドが呼ばれたかどうかで判定するようにして、その先で何が起きるかはPresenterは関心を持ちません。
- モデル層はMockitoを使ってテスト時に必要な処理を実装しています。
@RunWith(MockitoJUnitRunner::class) class InitializePresenterTest { @Mock lateinit var userRepository: UserRepository @Mock lateinit var userService: UserService @Mock lateinit var view: InitializeContact.View @Before fun setUp() { doAnswer { Unit }.`when`(view).navigateToMain() doAnswer { Unit }.`when`(view).showProgress() doAnswer { Unit }.`when`(view).hideProgress() doAnswer { Unit }.`when`(view).displayInvalidUserIdMessage() doAnswer { Unit }.`when`(view).showNetworkErrorMessage() RxAndroidPlugins.getInstance().reset() RxAndroidPlugins.getInstance().registerSchedulersHook(object : RxAndroidSchedulersHook() { override fun getMainThreadScheduler(): Scheduler? { return Schedulers.immediate() } }) } @After fun tearDown() { RxAndroidPlugins.getInstance().reset() } @Test fun testOnCreate_initialize_not_complete_register_user() { `when`(userRepository.resolve()).thenReturn(UserEntity("")) val presenter = InitializePresenter(userRepository, userService) presenter.onCreate(view) verify(view, never()).navigateToMain() } @Test fun testOnCreate_initialize_complete_register_user() { `when`(userRepository.resolve()).thenReturn(UserEntity("test")) val presenter = InitializePresenter(userRepository, userService) presenter.onCreate(view) verify(view).navigateToMain() } @Test fun testOnClickButtonSetId_success_check_id() { `when`(userRepository.resolve()).thenReturn(UserEntity("")) doAnswer { Unit }.`when`(userRepository).store(UserEntity("success")) `when`(userService.confirmExistingUserId("success")).thenReturn(Observable.just(true)) val presenter = InitializePresenter(userRepository, userService) presenter.onCreate(view) presenter.onResume() presenter.onClickButtonSetId("success") verify(userRepository, timeout(TimeUnit.SECONDS.toMillis(1))).store(UserEntity("success")) verify(view, timeout(TimeUnit.SECONDS.toMillis(1))).showProgress() verify(view, timeout(TimeUnit.SECONDS.toMillis(1))).hideProgress() verify(view, timeout(TimeUnit.SECONDS.toMillis(1))).navigateToMain() } @Test fun testOnClickButtonSetId_fail_check_id() { `when`(userRepository.resolve()).thenReturn(UserEntity("")) `when`(userService.confirmExistingUserId("fail")).thenReturn(Observable.just(false)) val presenter = InitializePresenter(userRepository, userService) presenter.onCreate(view) presenter.onResume() presenter.onClickButtonSetId("fail") verify(view, timeout(TimeUnit.SECONDS.toMillis(1))).showProgress() verify(view, timeout(TimeUnit.SECONDS.toMillis(1))).hideProgress() verify(view, timeout(TimeUnit.SECONDS.toMillis(1))).displayInvalidUserIdMessage() } @Test fun testOnClickButtonSetId_fail_check_id_404() { `when`(userRepository.resolve()).thenReturn(UserEntity("")) `when`(userService.confirmExistingUserId("fail")) .thenReturn(Observable.error(HttpException(Response.error<HttpException>(HttpURLConnection.HTTP_NOT_FOUND, ResponseBody.create(MediaType.parse("text/html"), ""))))) val presenter = InitializePresenter(userRepository, userService) presenter.onCreate(view) presenter.onResume() presenter.onClickButtonSetId("fail") verify(view, timeout(TimeUnit.SECONDS.toMillis(1))).showProgress() verify(view, timeout(TimeUnit.SECONDS.toMillis(1))).hideProgress() verify(view, timeout(TimeUnit.SECONDS.toMillis(1))).displayInvalidUserIdMessage() } @Test fun testOnClickButtonSetId_fail_check_id_network_error() { `when`(userRepository.resolve()).thenReturn(UserEntity("")) `when`(userService.confirmExistingUserId("fail")) .thenReturn(Observable.error(HttpException(Response.error<HttpException>(HttpURLConnection.HTTP_INTERNAL_ERROR, ResponseBody.create(MediaType.parse("text/html"), ""))))) val presenter = InitializePresenter(userRepository, userService) presenter.onCreate(view) presenter.onResume() presenter.onClickButtonSetId("fail") verify(view, timeout(TimeUnit.SECONDS.toMillis(1))).showProgress() verify(view, timeout(TimeUnit.SECONDS.toMillis(1))).hideProgress() verify(view, timeout(TimeUnit.SECONDS.toMillis(1))).showNetworkErrorMessage() } }
- これでgradleのtestタスクでUnitTestが回るようになるのでCircleCIなどの上で動かすようにしてあげれば継続的にテストを回せる仕組みができます。