All Articles

Robolectric 4: Bridging the gap between Robolectric and Espresso

In part 1 we went through how to setup shared test sources, wrote the first shared test and ran it on JVM and on a device.

In this part, we see how to workaround the difference between Robolectric and Espresso APIs.

How does Robolectric work

Robolectric’s official documentation provides a detailed outline of how it works. It mocks the Android framework by creating “Shadows” then uses byte code instrumentation to substitute native implementation with those “Shadows”.

You should also know that Robolectric executes layout() assuming zero-sized window. This introduces a lot of unknowns for when using Espresso APIs to test. In fact, it also introduces some subtle bugs when using Robolectric APIs such as this one and this one.

Failing test example

Let’s say we have an activity with a button, when this button is clicked a dialog opens.

A simple Espresso test can be

@RunWith(AndroidJUnit4::class)
class TextViewWithButtonActivityTest : BaseTest() {

    private lateinit var activityScenario: ActivityScenario<TextViewWithButtonActivity>

    @Before
    fun setup() {
        activityScenario = ActivityScenario.launch(TextViewWithButtonActivity::class.java)
    }

    @Test
    fun whenButtonClickedThenDialogIsShown_passing() {
        onView(withId(R.id.dialog_button)).perform(click())
        onView(withId(android.R.id.button1)).check(matches(isDisplayed()))
    }
}

where dialog_button is the id of our activity button and android.R.id.button1 is the id of the positive button in the dialog.

The problem is this test will work fine on the device but it will fail on the JVM.

Bridging the gap between Espresso and Robolectric

Abstraction can be our way to work around the inconsistencies between Robolectric and Espresso. Our test shouldn’t worry about the running environment, it should only care about asserting expected behaviour.

We should split the implementation of the problematic assertions according to the environment. Our test will only know about the interface.

Test assertions class diagram

The implementation for InstrumentationTestAssertion lives in androidTest while implementation for UnitTestAssertion lives in test.

interface UiTestAssertions {
    fun assertOnDialog(block: (View) -> Boolean)
}

class InstrumentationTestAssertion : UiTestAssertions {
    override fun assertOnDialog(block: (View) -> Boolean) {
        onView(withId(android.R.id.button1)).check(matches(isDisplayed()))
        onView(withId(android.R.id.content)).check { view, _ ->
            assertTrue(block(view))
        }
    }
}

class UnitTestAssertion : UiTestAssertions {
    override fun assertOnDialog(block: (View) -> Boolean) {
        val dialog = ShadowAlertDialog.getLatestDialog()
        assertNotNull(dialog)
        assertTrue(block(dialog.findViewById(android.R.id.content)))
    }
}

We can then do what Robolectric does to support Espresso test runners. Check which environment were are on

fun isART() = System.getProperty("java.runtime.name")?.toLowerCase()?.contains("android") == true

Then use reflection to create a new instance of the implementation.

Class.forName(
            if (isART()) {
                "me.amryousef.robotest.InstrumentationTestAssertion"
            } else {
                "me.amryousef.robotest.UnitTestAssertion"
            }
).newInstance() as UiTestAssertions

Note that Class.forName() needs the fully qualified name of the desired class.

Tidying up

Since there is some shared logic between all the shared classes we might have, it will better if we put that into a base class located in the shared test directory. This base class can then be used as a parent for all the test classes we add in the future. An example for this can be

abstract class BaseTest {

    val uiAssertions : UiTestAssertions by lazy {
        Class.forName(
            if (isART()) {
                "me.amryousef.robotest.InstrumentationTestAssertion"
            } else {
                "me.amryousef.robotest.UnitTestAssertion"
            }
        ).newInstance() as UiTestAssertions
    }

    private fun isART() = System.getProperty("java.runtime.name")?.toLowerCase()?.contains("android") == true
    
}

Finally our test should look like this

@RunWith(AndroidJUnit4::class)
class TextViewWithButtonActivityTest : BaseTest() {

    private lateinit var activityScenario: ActivityScenario<TextViewWithButtonActivity>

    @Before
    fun setup() {
        activityScenario = ActivityScenario.launch(TextViewWithButtonActivity::class.java)
    }

    @Test
    fun checkFirstTextHasText() {
        onView(withId(R.id.first_text)).check(matches(withText("This is first text")))
    }

    @Test
    fun checkSecondTextIsInvisible() {
        onView(withId(R.id.second_text)).check(matches(withEffectiveVisibility(ViewMatchers.Visibility.INVISIBLE)))
    }

    @Test
    fun whenButtonClickedThenDialogIsShown_passing() {
        onView(withId(R.id.dialog_button)).perform(click())
        uiAssertions.assertOnDialog {
            it.findViewById<TextView>(android.R.id.message).text.toString() == "Test passes if it can see this message"
                && it.findViewById<TextView>(android.R.id.message).isVisible
        }
    }
}