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.
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.
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
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))
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With