- 最近、Androidの設計やらテストの書き方やらを試行錯誤していて、ちょっと情報が散らばってきたので個人的なまとめです。これが絶対的にイケてる!とかじゃなくて単にいろんな人のスライド読んだり、自分で試したりして今こんな感じになったというレベルのものです。
- コードは↓で作ったアプリをベースにいじってます。。Kotlin製のアプリなのでJavaに適時読み替えてください。
rei19.hatenablog.com
設計の話
実際に作った例
- 起動時に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などの上で動かすようにしてあげれば継続的にテストを回せる仕組みができます。
おわり
- Androidのテストまわりがよくわかってなかったので、一旦立ち止まって調べたり試したりしたことを整理するためにメモを残してみました。
- サンプルコードをだらだらと貼ってしまいましたが、見返してみるとレイヤーを区切ったことでテストが書きやすくなったなという印象です。まだAndroid歴浅いので、間違ってるところがあるかもしれませんが、もっといいコードを書いてスピード感ある改修ができる作りを目指したいですね。