安卓Espresso等待文本出现

24

我正在尝试使用Espresso自动化一个Android聊天机器人应用程序。可以说我对Android应用程序自动化完全是新手。 目前,我在等待方面遇到了困难。如果我使用Thread.sleep,它可以完美地工作。但是,我想等待直到屏幕上出现特定的文本。我该怎么做?

@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("test@test.test"), 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 ..."

一些说明:在按下登录按钮后,聊天机器人会说“你好”并提供更多信息。我希望等待最后一条消息出现在屏幕上。

3个回答

36

我喜欢@jeprubio上面的答案,但是我遇到了与@desgraci在评论中提到的同样的问题,他们的匹配器不断地寻找旧的、陈旧的rootview上的视图。当你试图在测试中的活动之间进行转换时,这种情况经常发生。

我对传统的"隐式等待"模式的实现在下面的两个 Kotlin 文件中。

EspressoExtensions.kt 包含一个 searchFor 函数,一旦在所提供的 rootview 中找到匹配项,就返回一个 ViewAction。

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 调用 searchFor() 方法,并检查是否返回了匹配项。如果没有返回匹配项,则它会短暂休眠,然后获取新的根节点以进行匹配,直到尝试X次为止,然后它将抛出异常并导致测试失败。对“机器人”模式感到困惑吗?请查看 Jake Wharton 的这个精彩演讲,了解有关机器人模式的详细信息。它与页面对象模型模式非常相似。

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")
    }
}

使用它

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

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

我知道IdlingResource是Google推荐在Espresso测试中处理异步事件的方法,但通常需要将测试特定代码(即钩子)嵌入应用程序代码中以同步测试。这对我来说似乎很奇怪,在一个拥有成熟应用和多个开发人员每天提交代码的团队工作,为了测试而到处添加空闲资源似乎会增加很多额外的工作量。就个人而言,我更喜欢尽可能地将应用程序和测试代码分开。


4
绝对令人难以置信的解决方案,并且这个网站上最好的代码之一。谢谢! - Luke Needham
4
使用这种解决方案的一个要注意的问题是,即使可见性设置为不可见,matcher.matches(it)也会返回true,但是点击操作将失败,因为该元素不可见。解决方法很简单,只需使用matcher.matches(child).and(child.isVisible) - odiggity
1
onView(withId(someId)).check(matches(isDisplayed())) 可能会在第一次失败,抛出 NoMatchingViewException 异常,但如果你不断尝试该检查代码直到视图可见,它就会起作用。我正在使用这种策略来处理几秒钟后才出现的视图,并且一旦视图可见,检查就会成功通过。 - plgrenier
1
也许这不适用于你的使用情况。在我的情况下,所有事情都发生在一个主活动中,包括多个片段。如果我有空闲时间,我会尝试使用多个活动来测试我的策略是否仍然有效 :) - plgrenier
1
BaseRobot().assertOnView(withId(R.id.viewIWantToFind, matches(isDisplayed())) 这段代码中是否缺少一个右括号?正确的写法应该是在 R.id.viewIWantToFind 后面加上一个右括号。如果不加右括号,代码将无法编译通过,但即使加上右括号,也可能无法解决 deeplinking 的问题。 - JPM
显示剩余5条评论

16

您可以创建 空闲资源,或使用像这样的自定义 ViewAction

/**
 * 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();
        }
    };
}

你可以这样使用它:

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

如有必要,使用特定的id更改theIdToWaitFor并更新5秒(5000毫秒)的超时时间。


@jeprubio 这个 viewAction 很有趣,我正在尝试弄清楚如何使用这个 action。在上面的代码片段中,yourViewMatcher 到底是什么?谢谢。 - sowdri
我已经检查了我的代码,并且使用以下方式:onView(isRoot()).perform(waitId(R.id.theIdToWaitFor, 5000)); - jeprubio
@jeprubio 这太棒了。谢谢你。基本上我正在测试一个Firestore应用程序,其中更新是从服务器推送的,因此默认同步在这里不起作用,您的等待函数非常有帮助。 - sowdri
isRoot返回一个匹配根视图的匹配器。在这种情况下,您可以选择匹配屏幕上此时出现的任何视图,因为此匹配器的目的只是等待一段时间。 - jeprubio
注意这里有两个视图,一个是你正在等待的带有id的视图 R.id.theIdToWaitFor,另一个是匹配器的视图,你可以忽略并使用根视图。当具有该ID的视图可见或超时时间已过时,匹配器将完成。当然,这样会有一些延迟,如果你不想有延迟,你必须创建适当的空闲资源,这更具挑战性,但应该能够最小化延迟。 - jeprubio
显示剩余7条评论

4

如果你等待的文本是在一个 TextView 中,在登录完成之前不会进入视图层次结构,则建议使用本线程中操作根视图的其他答案之一(例如 这里这里)。

然而,如果你正在等待的文本需要更改的 TextView 已经存在于视图层次结构中,那么我强烈建议定义一个 ViewAction,该操作针对该 TextView 本身,以便在测试失败时获得更好的测试输出。

定义一个操作特定 TextViewViewAction,而不是在根视图上进行操作,是一个三步过程,具体如下:

首先,按以下方式定义 ViewAction 类:

/**
 * 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()
    }
}

其次,定义一个帮助函数来封装这个类,如下所示:
/**
 * @return a [WaitForTextAction] instance created with the given [text] and [timeout] parameters.
 */
fun waitForText(text: String, timeout: Long): ViewAction {
    return WaitForTextAction(text, timeout)
}

最后一步,调用如下辅助函数:

onView(withId(R.id.someTextView)).perform(waitForText("Some text", 5000))

1
这个对于动态文本功能来说对我来说很有用,谢谢。在 Kotlin 中,如果需要的话,您可以跳过类创建步骤,只需做 fun waitForText(text: String, timeout: Long): ViewAction = object : ViewAction { /* implement members */ } - Tina

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接