Espresso UI Test For Data-Binding

Peike Dai
AndroidPub
Published in
5 min readAug 15, 2019

Writing UI test is a great way to automate and simplify the QA process in the mobile development cycle. For Android, Espresso is the best option so far.

Almost all apps nowadays require asynchronous actions to either retrieve data or perform a complex calculation. Because UI test is simulating the use of a real user, aka. “testing fidelity”, the testing framework can handle the asynchronousness in all kinds of ways.

The way Espresso recommends is using Idling Resources. It can tell the developer when the asynchronous action is completed. So the test case will only be executed and the checks will only be verified when the app is “idle”. This is a great article by a Google Developer introducing how to properly use Idling Resource.

However, the way Idling Resource works makes it impossible to know when a data-binding action is completed. Because of that, Espresso may think the app is idle while the data-binding action hasn’t completed.

So as of now, Espresso does not work when the testing class uses data-binding.

Of course, people have found a workaround solution. So whenever onView is called, the developer-defined IdlingResource will be called to check if it is idle now. What this workaround does is it creates a custom IdlingResource. When the isIdleNow() is called, it fetches the actual binding classes and calls hasPendingBinding() to check if the data-binding action has done. The great about this solution is we don’t have to add any code in our app, which is actually the recommended approach to use Idling Resource.

// source: link
class DataBindingIdlingResource: IdlingResource {
override fun isIdleNow(): Boolean {
val idle = !getBindings().any { it.hasPendingBindings() }
...
}
private fun getBindings(): List<ViewDataBinding> {
return (activityTestRule.activity as? FragmentActivity)
?.supportFragmentManager
?.fragments
?.mapNotNull {
it.view?.let { view ->
DataBindingUtil.getBinding<ViewDataBinding>(
view
)
}
} ?: emptyList()
}
}

The Real-Life Scenario

So I copied and used it in my project. Oh speaking of my project, what I want to test is a splash screen which shows on app startup. On that screen, it first shows the logo in the center, then transitions to a logo on top, welcome message, and a login button. The center logo is then hidden.

Here is the test case.

SplashActivityTest.kt

As you can see, my splash screen is an Activity. I use ViewModel for it, and the data-binding happens on ObservableField inside ViewModel. The ObservableField is used in the activity_splash.xml

This type of issue that test case sometimes pass but sometimes fail is usually caused by Race Condition.

But an issue was found when executing this single test case. It does not always pass. Sometimes it fails but sometimes not. And the failure is always the same: the center logo is expected to be hidden but it’s not.

From my experience, this type of issue that sometimes pass sometimes fail is usually caused by Race Condition. That means, our DataBindingIdlingResource does not work. The Choreographer and Main Looper are running parallelly. When the data-binding actions took a shorter time than the empty of the main looper’s MessageQueue, then the test passes. Otherwise, it fails. The DataBindingIdlingResource is supposed to solve this.

What we can do now is to look into the code of DataBindingIdlingResource.

Look Into DataBindingIdlingResource

DataBindingIdlingResource

The first thing to debug is to check if !getBindings().any { it.hasPendingBindings() } ever returns false. So I put a log under it.

Timber.d("data binding is idle ${idle}")

When running the test again, I found that this log is printed many times, and idle is always true. Great! Our assumption of Race Condition is correct and the problem is inside either getBindings() or hasPendingBindings(). Because it should return false at least once or twice.

Now let’s look at the getBindings() first.

// source
private fun getBindings(): List<ViewDataBinding> {
return (activityTestRule.activity as? FragmentActivity)
?.supportFragmentManager
?.fragments
?.mapNotNull {
it.view?.let { view ->
DataBindingUtil.getBinding<ViewDataBinding>(
view
)
}
} ?: emptyList()
}

Wait a minute, so it only returns the bindings of fragments, but not activity? I think I found what’s wrong. Looks like that Google sample project does not test activities’ data-binding. So we need to add our own.

We need to get the binding of the SplashActivity. Here we already have the reference to the activity: activityTestRule.activity. We can still use the static method DataBindingUtil.getBinding<ViewDataBinding>(). Now all we need is the view. And for an Activity, it would be the root view.

From this SO answer, we know we can use android.R.id.content to obtain the root view. So the getBindings() was changed to.

private fun getBindings(): List<ViewDataBinding> {
val fragmentBindings =
(activityTestRule.activity as? FragmentActivity)
?.supportFragmentManager
?.fragments
?.mapNotNull {
it.view?.let { view ->
DataBindingUtil.getBinding<ViewDataBinding>(
view
)
}
} ?: emptyList()
val activityBinding = activityTestRule.activity
.findViewById<View>(android.R.id.content).let {
DataBindingUtil.getBinding(it)
}
return (fragmentBindings + activityBinding).filterNotNull()

It looks great! Let’s run again!

Yep, it doesn’t work. The test is still not 100% pass.

After some debugging, I found that getBindings() always returns an empty list. Hmm, why does the DataBindingUti.getBinding(androidRootView) return null?

Look Into Data-Binding

To find out that, we need to look into the generated data-binding classes. When we integrate an Activity to data-binding, we call DataBindingUtil.setContentView(activity, layoutId). This method internally calls an overloaded static method:

Copied from Android Studio cause I can’t find it online

First, data-binding is using android.R.id.content to find the root view too. But inside the bindToAddedViews(), a method bind() is called by different condition and different parameter. Let’s look at the first condition.

final int endChildren = parent.getChildCount();
final int childrenAdded = endChildren - startChildren;
if (childrenAdded == 1) {
final View childView = parent.getChildAt(endChildren - 1);
return bind(component, childView, layoutId);
}

So if the parent is the activity’s root view, it’ll only have one child view which is the root view in the activity’s xml. In our case it’ll be the root view in activity_splash.xml. So childrenAdded == 1 will be true here in our case. And the childView which is the activity_splash.xml root view will be passed into bind(). Does this mean the binding class is attached to the childView? Let’s change our getBindings() a little bit.

val activityBinding = activityTestRule.activity
.findViewById<View>(android.R.id.content)
.getChildAt(0).let {
DataBindingUtil.getBinding(it)
}
return (fragmentBindings + activityBinding).filterNotNull()

And when I run it again, the test is 100% pass.

WooHoo!

Here is the recap.

Espresso doesn’t wait for data-binding to complete. A workaround class was found. But it only supports checking fragment binding not activity binding. So some modification is needed to that file. After looking into the data-binding source code, we added some fix and our test is 100% pass.

Happy Coding!

--

--