æè¿ã®å人çãªAndroidã®è¨è¨ã¨ããã¹ãå¨ãã¨ãã¾ã¨ã
- æè¿ã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ã使ãã¨ããã¹ãããªãããã¨æãã¾ãã
ãã¹ãã®è©±
- ããã§ã¯ä¸»ã«UnitTestã¬ãã«ã®è©±ããã¾ããAndroidã«ããããã¹ãã¯åç´ãªJUnitã®ãã¹ãã¨ã¨ãã¥ã¬ã¼ã¿ã¼ãå®æ©ã使ã£ããã¹ãã§åããã¾ãããåè ãæãã¾ãã
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æ´æµ ãã®ã§ãééã£ã¦ãã¨ãããããããããã¾ãããããã£ã¨ããã³ã¼ããæ¸ãã¦ã¹ãã¼ãæããæ¹ä¿®ãã§ããä½ããç®æãããã§ããã