Friday 21 August 2020

Espresso tests: Match child view by position within RecyclerView

Let's say you want to write a user interface test that matches the child view at a particular position within a RecyclerView and you want to assert some properties on that child view. When you search for this online, you'll come across a whole host of solutions that do work but, sadly, are not very fluent or Espresso-esque. You'll see solutions that will lead you to write assertions like the following:

onView(withRecyclerView(R.id.recyclerView).atPositionOnView(0))
    .check(matches(withText("Some Text")))

onView(withId(R.id.recyclerView))
    .check(matches(atPosition(0, withText("Some Text"))))

Like I said: sure, this works, but I feel we can do better. Most notably, what I would change about these is to match on the child view of interest instead and assert properties on that rather than matching on the entire RecyclerView. So what we're aiming for are assertions like the following:

onView(withPositionInRecyclerView(R.id.recyclerView, 0))
    .check(matches(withText("Some Text")))

It's a subtle change and maybe it's just me but I feel this is a little easier on the eye and makes the reading experience a little more pleasurable. If you've made it this far and you're bought in, here's the Matcher class you need to define to make this possible:

/**
 * A matcher that matches the child [View] at the given position
 * within the [RecyclerView] which has the given resource id.
 *
 * Note that it's necessary to scroll the [RecyclerView] to the desired position
 * before attempting to match the child [View] at that position.
 */
class WithPositionInRecyclerViewMatcher(private val recyclerViewId: Int,
                                        private val position: Int) : TypeSafeMatcher<View>() {

    override fun describeTo(description: Description) {
        description.appendText("with position $position in RecyclerView which has id $recyclerViewId")
    }

    override fun matchesSafely(item: View): Boolean {
        val parent = item.parent as? RecyclerView
            ?: return false

        if (parent.id != recyclerViewId)
            return false

        val viewHolder: RecyclerView.ViewHolder = parent.findViewHolderForAdapterPosition(position)
            ?: return false // has no item on such position

        return item == viewHolder.itemView
    }
}

And, for best practice, define a function that wraps this class as follows:

/**
 * @return an instance of [WithPositionInRecyclerViewMatcher] created with the given parameters.
 */
fun withPositionInRecyclerView(recyclerViewId: Int, position: Int): Matcher<View> {
    return WithPositionInRecyclerViewMatcher(recyclerViewId, position)
}

That's it. We're done. Happy testing and for more view matchers like this one, check out the android-test-utils repository.

Monday 17 August 2020

Espresso tests: Wait until view is visible

Whilst searching online for some suggestions on how to wait for a particular View to become visible within an Espresso test, I noticed that all of the suggestions that defined a new ViewAction class, defined one that operated on the root view. I can see why this is necessary in situations where the View in question is not present in the view hierarchy and will enter the view hierarchy at a later point. However, if you're waiting on a View that's present in the view hierarchy to change from one state to another, it's much more elegant to match and operate on that one View specifically rather than matching and operating on the root view.

So, assuming you're waiting on a View that's present in the view hierarchy to change from INVISIBLE or GONE visibility to VISIBLE, you can define a ViewAction class as follows:

/**
 * A [ViewAction] that waits up to [timeout] milliseconds for a [View]'s visibility value to change to [View.VISIBLE].
 */
class WaitUntilVisibleAction(private val timeout: Long) : ViewAction {

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

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

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

        val endTime = System.currentTimeMillis() + timeout

        do {
            if (view.visibility == View.VISIBLE) return
            uiController.loopMainThreadForAtLeast(50)
        } while (System.currentTimeMillis() < endTime)

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

And define a function that creates an instance of this ViewAction when called, as follows:

/**
 * @return a [WaitUntilVisibleAction] instance created with the given [timeout] parameter.
 */
fun waitUntilVisible(timeout: Long): ViewAction {
    return WaitUntilVisibleAction(timeout)
}

You can then call on this ViewAction in your test methods as follows:

onView(withId(R.id.myView)).perform(waitUntilVisible(3000L))

You can run with this concept and similarly define view actions that wait on other properties of the view to change state, e.g. waiting for the text of a TextView to change to some expected text.

For more view actions and view matchers like this one, check out the android-test-utils repository.

Espresso tests: Match child view by position within parent

I noticed whilst browsing online that all of the answers to the question "can I match the child at a particular index within a particular parent view" required the definition of a new Matcher class. Whilst it's not a huge deal to define a new Matcher class as and when needed, it's not necessary in this case. You can instead get a handle on a particular child of a particular view by joining up the view matchers offered by Espresso into a method as follows:
/**
 * @param parentViewId the resource id of the parent [View].
 * @param position the child index of the [View] to match.
 * @return a [Matcher] that matches the child [View] which has the given [position] within the specified parent.
 */
fun withPositionInParent(parentViewId: Int, position: Int): Matcher<View> {
    return allOf(withParent(withId(parentViewId)), withParentIndex(position))
}

You can use this method as follows:

onView(
    withPositionInParent(R.id.parent, 0)
).check(
    matches(withId(R.id.child))
)

For more view actions and view matchers like this one, check out the android-test-utils repository.

Thursday 30 July 2020

Xcode UI tests: saveScreenshot(...) extension function

Taking a screenshot as part of an Xcode UI test and adding the screenshot to the test's output is a five-step shuffle as follows:

let screenshot = XCUIApplication().screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.lifetime = .keepAlways
attachment.name = "SomeName"
add(attachment)

Wouldn't it be great if this could be reduced to a single line call as follows:

XCUIApplication().saveScreenshot(to: self, named: "SomeName")

To make this possible, all you need to do is add the following extension function to your UI testing bundle:

extension XCUIScreenshotProviding {
    
    func saveScreenshot(to activity: XCTActivity, named name: String) {
        let attachment = XCTAttachment(screenshot: screenshot())
        attachment.lifetime = .keepAlways
        attachment.name = name
        activity.add(attachment)
    }
}
You can find this extension function and others like it in the XCTestExtensions repo.

Saturday 25 July 2020

Xcode UI tests: waitForNonExistence(...) extension function

The XCUIElement.waitForExistence(timeout:) function is great for waiting on an element to appear whilst an animation or asynchronous operation completes. However, there's equally a need at times to wait for an element to disappear and, sadly, XCUIElement does not offer such a function. Good news though! It turns out that it's not difficult at all to compose an extension function on XCUIElement which does exactly this, as follows:

extension XCUIElement {

    /**
     * Waits the specified amount of time for the element’s `exists` property to become `false`.
     *
     * - Parameter timeout: The amount of time to wait.
     * - Returns: `false` if the timeout expires without the element coming out of existence.
     */
    func waitForNonExistence(timeout: TimeInterval) -> Bool {

        let timeStart = Date().timeIntervalSince1970

        while (Date().timeIntervalSince1970 <= (timeStart + timeout)) {
            if !exists { return true }
        }
 
        return false
    }

You can find this extension function and others like it in the XCTestExtensions repo.