I have a problem when I test my login screen which has custom EditText in it. When I try run the test, the test always loading and nothing happen until I get AppNotIdleException. I'd disabled my system animation when run the test, and finally try remove my custom EditText view and the test running.
Here is my code:
fragment_login.xml
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:keep="@layout/fragment_login">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/lottie_auth"
android:layout_width="0dp"
android:layout_height="250dp"
android:layout_marginStart="32dp"
android:layout_marginEnd="32dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
android:alpha="0"
app:layout_constraintTop_toBottomOf="@+id/tv_greeting"
app:lottie_autoPlay="true"
app:lottie_loop="true"
app:lottie_rawRes="@raw/watermelon_lottie" />
<TextView
android:id="@+id/tv_greeting"
style="@style/TextAppearance.StoryApp.Headline"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:alpha="0"
android:layout_marginTop="32dp"
android:layout_marginEnd="20dp"
android:text="@string/greeting2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_login"
style="@style/TextAppearance.StoryApp.Title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:alpha="0"
android:layout_marginTop="16dp"
android:text="@string/login"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/lottie_auth" />
<com.ekotyoo.storyapp.ui.EmailEditText
android:id="@+id/et_email"
android:layout_width="0dp"
android:layout_height="50dp"
android:layout_marginStart="20dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="20dp"
android:alpha="0"
android:inputType="textEmailAddress"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tv_login" />
<com.ekotyoo.storyapp.ui.PasswordEditText
android:id="@+id/et_password"
android:layout_width="0dp"
android:layout_height="50dp"
android:layout_marginStart="20dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="20dp"
android:alpha="0"
android:inputType="textPassword"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/et_email" />
<Button
android:id="@+id/btn_login"
style="@style/TextAppearance.StoryApp.Button"
android:layout_width="0dp"
android:layout_height="50dp"
android:layout_marginStart="20dp"
android:layout_marginTop="32dp"
android:layout_marginEnd="20dp"
android:alpha="0"
android:background="@drawable/bg_button"
android:text="@string/login"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/et_password" />
<TextView
android:id="@+id/tv_or_signup"
style="@style/TextAppearance.StoryApp.Body1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:alpha="0"
android:text="@string/or_signup"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/btn_login" />
<ProgressBar
android:id="@+id/loading_progress_bar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
PasswordEditText.kt:
package com.ekotyoo.storyapp.ui
import android.content.Context
import android.graphics.Canvas
import android.graphics.drawable.Drawable
import android.text.method.PasswordTransformationMethod
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import androidx.appcompat.widget.AppCompatEditText
import androidx.core.content.ContextCompat
import androidx.core.widget.doOnTextChanged
import com.ekotyoo.storyapp.R
class PasswordEditText : AppCompatEditText, View.OnTouchListener {
private val roundedBackground =
ContextCompat.getDrawable(context, R.drawable.bg_custom_edittext)
private lateinit var hidePasswordIcon: Drawable
private lateinit var showPasswordIcon: Drawable
private var isHidden = true
private val passwordTransformationMethod = PasswordTransformationMethod.getInstance()
constructor(context: Context) : super(context) {
init()
}
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
init()
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(
context,
attrs,
defStyleAttr
) {
init()
}
private fun init() {
setOnTouchListener(this)
doOnTextChanged { text, _, _, _ ->
text?.let {
if (it.length < 6 && it.isNotBlank()) setError(
context.applicationContext.getString(
R.string.password_less_than_6
), null
)
}
}
hidePasswordIcon =
ContextCompat.getDrawable(context, R.drawable.ic_eye_disabled) as Drawable
showPasswordIcon = ContextCompat.getDrawable(context, R.drawable.ic_eye) as Drawable
hint = "Password"
textSize = 14F
textAlignment = View.TEXT_ALIGNMENT_VIEW_START
background = roundedBackground
}
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
transformationMethod = if (isHidden) {
showPasswordVisible()
passwordTransformationMethod
} else {
showPasswordHidden()
null
}
}
private fun setDrawables(
left: Drawable? = null,
top: Drawable? = null,
right: Drawable? = null,
bottom: Drawable? = null,
) {
setCompoundDrawablesWithIntrinsicBounds(left, top, right, bottom)
}
private fun showPasswordVisible() {
setDrawables(null, null, null, null)
setDrawables(null, null, showPasswordIcon, null)
}
private fun showPasswordHidden() {
setDrawables(null, null, null, null)
setDrawables(null, null, hidePasswordIcon, null)
}
override fun onTouch(view: View?, event: MotionEvent): Boolean {
if (compoundDrawables[2] != null) {
val toggleButtonStart: Float
val toggleButtonEnd: Float
var isToggleButtonClicked = false
if (layoutDirection == View.LAYOUT_DIRECTION_RTL) {
toggleButtonEnd = (hidePasswordIcon.intrinsicWidth + paddingStart).toFloat()
if (event.x < toggleButtonEnd) isToggleButtonClicked = true
} else {
toggleButtonStart = (width - paddingEnd - hidePasswordIcon.intrinsicWidth).toFloat()
if (event.x > toggleButtonStart) isToggleButtonClicked = true
}
if (isToggleButtonClicked) {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
hidePasswordIcon = ContextCompat.getDrawable(
context,
R.drawable.ic_eye_disabled
) as Drawable
showPasswordIcon =
ContextCompat.getDrawable(context, R.drawable.ic_eye) as Drawable
isHidden = !isHidden
return true
}
}
}
}
return false
}
}
LoginFragment.kt
package com.ekotyoo.storyapp.ui.login
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import com.ekotyoo.storyapp.R
import com.ekotyoo.storyapp.databinding.FragmentLoginBinding
import com.ekotyoo.storyapp.utils.Utils
import com.ekotyoo.storyapp.utils.ViewModelFactory
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
class LoginFragment : Fragment() {
private var _binding: FragmentLoginBinding? = null
private val binding get() = _binding!!
private val viewModel: LoginViewModel by viewModels {
ViewModelFactory.getInstance(requireContext())
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentLoginBinding.inflate(layoutInflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupView()
playAnimation()
observeViewModel()
}
private fun setupView() {
binding.btnLogin.setOnClickListener {
val email = binding.etEmail.text.toString().trim()
val password = binding.etPassword.text.toString().trim()
viewModel.login(email, password)
}
binding.tvOrSignup.setOnClickListener {
findNavController().navigate(R.id.action_loginFragment_to_signupFragment)
}
validateForm()
}
private fun validateForm() {
val email = MutableStateFlow("")
val password = MutableStateFlow("")
with(binding) {
etEmail.doAfterTextChanged { text -> email.value = text.toString().trim() }
etPassword.doAfterTextChanged { text -> password.value = text.toString().trim() }
}
lifecycleScope.launch {
combine(email, password) { e, p ->
Utils.validateEmail(e) && Utils.validatePassword(p)
}.collect { isValid ->
binding.btnLogin.isEnabled = isValid
}
}
}
private fun playAnimation() {
val greeting = ObjectAnimator.ofFloat(binding.tvGreeting, View.ALPHA, 1F).setDuration(500L)
val lottie = ObjectAnimator.ofFloat(binding.lottieAuth, View.ALPHA, 1f).setDuration(500L)
val loginAlpha = ObjectAnimator.ofFloat(binding.tvLogin, View.ALPHA, 1F).setDuration(500L)
val loginTranslateX =
ObjectAnimator.ofFloat(binding.tvLogin, View.TRANSLATION_X, -100F, 0F).setDuration(500L)
val etEmailAlpha = ObjectAnimator.ofFloat(binding.etEmail, View.ALPHA, 1F).setDuration(500L)
val etEmailTranslateX =
ObjectAnimator.ofFloat(binding.etEmail, View.TRANSLATION_X, -100F, 0F).setDuration(500L)
val etPassAlpha =
ObjectAnimator.ofFloat(binding.etPassword, View.ALPHA, 1F).setDuration(500L)
val etPassTranslateX =
ObjectAnimator.ofFloat(binding.etPassword, View.TRANSLATION_X, -100F, 0F)
.setDuration(500L)
val btnLoginAlpha =
ObjectAnimator.ofFloat(binding.btnLogin, View.ALPHA, 1F).setDuration(500L)
val btnLoginTranslateX =
ObjectAnimator.ofFloat(binding.btnLogin, View.TRANSLATION_X, -100F, 0F)
.setDuration(500L)
val orSignup = ObjectAnimator.ofFloat(binding.tvOrSignup, View.ALPHA, 1F).setDuration(500L)
val login = AnimatorSet().apply { playTogether(loginAlpha, loginTranslateX) }
val email = AnimatorSet().apply { playTogether(etEmailAlpha, etEmailTranslateX) }
val password = AnimatorSet().apply { playTogether(etPassAlpha, etPassTranslateX) }
val btnLogin = AnimatorSet().apply { playTogether(btnLoginAlpha, btnLoginTranslateX) }
AnimatorSet().apply {
playSequentially(greeting, lottie, login, email, password, btnLogin, orSignup)
start()
}
}
private fun observeViewModel() {
viewModel.userModel.observe(viewLifecycleOwner) { userModel ->
if (userModel?.isLoggedIn == true) {
findNavController().navigate(R.id.action_loginFragment_to_homeFragment)
}
}
viewModel.errorMessage.observe(viewLifecycleOwner) { message ->
Utils.showToast(requireContext(), message)
}
viewModel.isLoading.observe(viewLifecycleOwner) { state ->
showLoading(state)
}
}
private fun showLoading(isLoading: Boolean) {
binding.loadingProgressBar.visibility = if (isLoading) View.VISIBLE else View.GONE
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
MainActivityTest.kt
package com.ekotyoo.storyapp.ui
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.pressBack
import androidx.test.espresso.IdlingRegistry
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.typeText
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import com.ekotyoo.storyapp.R
import com.ekotyoo.storyapp.utils.EspressoIdlingResource
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@LargeTest
class MainActivityTest {
@get:Rule
val activity = ActivityScenarioRule(MainActivity::class.java)
@Before
fun setUp() {
IdlingRegistry.getInstance().register(EspressoIdlingResource.countingIdlingResource)
}
@After
fun tearDown() {
IdlingRegistry.getInstance().unregister(EspressoIdlingResource.countingIdlingResource)
}
@Test
fun load_story() {
onView(withId(R.id.rv_story)).check(matches(isDisplayed()))
onView(withId(R.id.rv_story)).perform(
RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(
2
)
)
}
@Test
fun go_to_detail_story() {
onView(withId(R.id.rv_story)).check(matches(isDisplayed()))
onView(withId(R.id.rv_story)).perform(
RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(
2,
click()
)
)
}
@Test
fun go_to_map_story() {
onView(withId(R.id.btn_map)).perform(click())
onView(withId(R.id.btn_back)).check(matches(isDisplayed()))
pressBack()
}
@Test
fun go_to_post_story() {
onView(withId(R.id.btn_add_story)).perform(click())
onView(withId(R.id.btn_change_image)).perform(click())
pressBack()
onView(withId(R.id.et_caption)).perform(typeText("Hello World"))
pressBack()
}
@Test
fun open_popup_menu() {
onView(withId(R.id.btn_menu)).perform(click())
}
@Test
fun login() {
onView(withId(R.id.et_email)).perform(typeText("email@mail.com"))
onView(withId(R.id.et_password)).perform(typeText("123456"))
onView(withId(R.id.btn_login)).perform(click())
}
}