Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Android Espresso wait for text to appear

I am trying to automate an Android app that is a chatbot using Espresso. I can say that I am completely new to Android app automation. Right now I am struggled with waiting. If I use Thread.sleep, it works perfectly fine. However, I would like to wait until a specific text appears on the screen. How can I do that?

@Rule
public ActivityTestRule<LoginActivity> mActivityTestRule = new ActivityTestRule<>(LoginActivity.class);

@Test
public void loginActivityTest() {
ViewInteraction loginName = onView(allOf(withId(R.id.text_edit_field),
childAtPosition(childAtPosition(withId(R.id.email_field),0), 1)));
loginName.perform(scrollTo(), replaceText("[email protected]"), closeSoftKeyboard());

ViewInteraction password= onView(allOf(withId(R.id.text_edit_field),
childAtPosition(childAtPosition(withId(R.id.password_field),0), 1)));
password.perform(scrollTo(), replaceText("12345678"), closeSoftKeyboard());

ViewInteraction singInButton = onView(allOf(withId(R.id.sign_in), withText("Sign In"),childAtPosition(childAtPosition(withId(R.id.scrollView), 0),2)));
singInButton .perform(scrollTo(), click());

//Here I need to wait for the text "Hi ..."

Some explanations: after pressing the sign in button, the chatbot says "Hi" and gives some more information. I would like to wait for the last one message to appear on the screen.

like image 280
Anna Puskarjova Avatar asked Apr 12 '18 12:04

Anna Puskarjova


3 Answers

You can either create an idling resource or use a custom ViewAction as this one:

/**
 * Perform action of waiting for a specific view id.
 * @param viewId The id of the view to wait for.
 * @param millis The timeout of until when to wait for.
 */
public static ViewAction waitId(final int viewId, final long millis) {
    return new ViewAction() {
        @Override
        public Matcher<View> getConstraints() {
            return isRoot();
        }

        @Override
        public String getDescription() {
            return "wait for a specific view with id <" + viewId + "> during " + millis + " millis.";
        }

        @Override
        public void perform(final UiController uiController, final View view) {
            uiController.loopMainThreadUntilIdle();
            final long startTime = System.currentTimeMillis();
            final long endTime = startTime + millis;
            final Matcher<View> viewMatcher = withId(viewId);

            do {
                for (View child : TreeIterables.breadthFirstViewTraversal(view)) {
                    // found view with required ID
                    if (viewMatcher.matches(child)) {
                        return;
                    }
                }

                uiController.loopMainThreadForAtLeast(50);
            }
            while (System.currentTimeMillis() < endTime);

            // timeout happens
            throw new PerformException.Builder()
                    .withActionDescription(this.getDescription())
                    .withViewDescription(HumanReadables.describe(view))
                    .withCause(new TimeoutException())
                    .build();
        }
    };
}

And you can use it this way:

onView(isRoot()).perform(waitId(R.id.theIdToWaitFor, 5000));

changing theIdToWaitFor with the specific id and update the timeout of 5 secs (5000 millis) if necessary.

like image 177
jeprubio Avatar answered Oct 18 '22 23:10

jeprubio


I like @jeprubio's answer above, however I ran into the same issue @desgraci mentioned in the comments, where their matcher is consistently looking for a view on an old, stale rootview. This happens frequently when trying to have transitions between activities in your test.

My implementation of the traditional "Implicit Wait" pattern lives in the two Kotlin files below.

EspressoExtensions.kt contains a function searchFor which returns a ViewAction once a match has been found within supplied rootview.

class EspressoExtensions {

    companion object {

        /**
         * Perform action of waiting for a certain view within a single root view
         * @param matcher Generic Matcher used to find our view
         */
        fun searchFor(matcher: Matcher<View>): ViewAction {

            return object : ViewAction {

                override fun getConstraints(): Matcher<View> {
                    return isRoot()
                }

                override fun getDescription(): String {
                    return "searching for view $matcher in the root view"
                }

                override fun perform(uiController: UiController, view: View) {

                    var tries = 0
                    val childViews: Iterable<View> = TreeIterables.breadthFirstViewTraversal(view)

                    // Look for the match in the tree of childviews
                    childViews.forEach {
                        tries++
                        if (matcher.matches(it)) {
                            // found the view
                            return
                        }
                    }

                    throw NoMatchingViewException.Builder()
                        .withRootView(view)
                        .withViewMatcher(matcher)
                        .build()
                }
            }
        }
    }
}

BaseRobot.kt calls the searchFor() method, checks if a matcher was returned. If no match is returned, it sleeps a tiny bit and then fetches a new root to match on until it has tried X times, then it throws an exception and the test fails. Confused about what a "Robot" is? Check out this fantastic talk by Jake Wharton about the Robot pattern. Its very similar to the Page Object Model pattern

open class BaseRobot {

    fun doOnView(matcher: Matcher<View>, vararg actions: ViewAction) {
        actions.forEach {
            waitForView(matcher).perform(it)
        }
    }

    fun assertOnView(matcher: Matcher<View>, vararg assertions: ViewAssertion) {
        assertions.forEach {
            waitForView(matcher).check(it)
        }
    }

    /**
     * Perform action of implicitly waiting for a certain view.
     * This differs from EspressoExtensions.searchFor in that,
     * upon failure to locate an element, it will fetch a new root view
     * in which to traverse searching for our @param match
     *
     * @param viewMatcher ViewMatcher used to find our view
     */
    fun waitForView(
        viewMatcher: Matcher<View>,
        waitMillis: Int = 5000,
        waitMillisPerTry: Long = 100
    ): ViewInteraction {

        // Derive the max tries
        val maxTries = waitMillis / waitMillisPerTry.toInt()

        var tries = 0

        for (i in 0..maxTries)
            try {
                // Track the amount of times we've tried
                tries++

                // Search the root for the view
                onView(isRoot()).perform(searchFor(viewMatcher))

                // If we're here, we found our view. Now return it
                return onView(viewMatcher)

            } catch (e: Exception) {

                if (tries == maxTries) {
                    throw e
                }
                sleep(waitMillisPerTry)
            }

        throw Exception("Error finding a view matching $viewMatcher")
    }
}

To use it

// Click on element withId
BaseRobot().doOnView(withId(R.id.viewIWantToFind), click())

// Assert element withId is displayed
BaseRobot().assertOnView(withId(R.id.viewIWantToFind), matches(isDisplayed()))

I know that IdlingResource is what Google preaches to handle asynchronous events in Espresso testing, but it usually requires that you have test specific code (i.e hooks) embedded within your app code in order to synchronize the tests. That seems weird to me, and working on a team with a mature app and multiple developers committing code everyday, it seems like it would be a lot of extra work to retrofit idling resources everywhere in the app just for the sake of tests. Personally, I prefer to keep the app and test code as separate as possible. /end rant

like image 32
manbradcalf Avatar answered Oct 18 '22 23:10

manbradcalf


If the text that you're waiting on is in a TextView that won't enter the view hierarchy until after the sign-in is complete, then I suggest going with one of the other answers in this thread which operate on the root view (i.e. here or here).

However, if you're waiting on the text to change in a TextView that is already present in the view hierarchy, then I would strongly suggest defining a ViewAction that operates on the TextView itself for better test output in the case of test failure.

Defining a ViewAction that operates on a particular TextView instead of operating on the root view is a three-step process as below.

Firstly, define the ViewAction class as follows:

/**
 * A [ViewAction] that waits up to [timeout] milliseconds for a [View]'s text to change to [text].
 *
 * @param text the text to wait for.
 * @param timeout the length of time in milliseconds to wait for.
 */
class WaitForTextAction(private val text: String,
                        private val timeout: Long) : ViewAction {

    override fun getConstraints(): Matcher<View> {
        return isAssignableFrom(TextView::class.java)
    }

    override fun getDescription(): String {
        return "wait up to $timeout milliseconds for the view to have text $text"
    }

    override fun perform(uiController: UiController, view: View) {
        val endTime = System.currentTimeMillis() + timeout

        do {
            if ((view as? TextView)?.text == text) return
            uiController.loopMainThreadForAtLeast(50)
        } while (System.currentTimeMillis() < endTime)

        throw PerformException.Builder()
                .withActionDescription(description)
                .withCause(TimeoutException("Waited $timeout milliseconds"))
                .withViewDescription(HumanReadables.describe(view))
                .build()
    }
}

Secondly, define a helper function that wraps this class as follows:

/**
 * @return a [WaitForTextAction] instance created with the given [text] and [timeout] parameters.
 */
fun waitForText(text: String, timeout: Long): ViewAction {
    return WaitForTextAction(text, timeout)
}

Thirdly and finally, call on the helper function as follows:

onView(withId(R.id.someTextView)).perform(waitForText("Some text", 5000))
like image 3
Adil Hussain Avatar answered Oct 18 '22 23:10

Adil Hussain