androidengineers.Book a session

Testing Architecture

Espresso & Robolectric

article25 minMedium

Espresso tests run on a real device or emulator and interact with the actual UI. Robolectric simulates the Android environment on the JVM, allowing tests that need Android APIs to run without a device.

Espresso: Real Device UI Tests

Espresso provides a fluent API to find views, perform actions, and assert their state:

// androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
// androidTestImplementation("androidx.test:rules:1.5.0")

@RunWith(AndroidJUnit4::class)
class LoginActivityTest {

    @get:Rule
    val activityRule = ActivityScenarioRule(LoginActivity::class.java)

    @Test
    fun validCredentialsShowsHomeScreen() {
        // Find view → perform action
        onView(withId(R.id.email_field))
            .perform(click(), typeText("user@example.com"), closeSoftKeyboard())

        onView(withId(R.id.password_field))
            .perform(click(), typeText("password123"), closeSoftKeyboard())

        onView(withId(R.id.login_button))
            .perform(click())

        // Assert
        onView(withText("Welcome!"))
            .check(matches(isDisplayed()))
    }

    @Test
    fun emptyPasswordShowsError() {
        onView(withId(R.id.email_field))
            .perform(typeText("user@example.com"), closeSoftKeyboard())

        onView(withId(R.id.login_button))
            .perform(click())

        onView(withText("Password is required"))
            .check(matches(isDisplayed()))
    }
}

Compose UI Tests (Espresso alternative for Compose)

// androidTestImplementation("androidx.compose.ui:ui-test-junit4")

@RunWith(AndroidJUnit4::class)
class ArticleScreenTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun articleDisplaysCorrectly() {
        val testArticle = Article("a1", "Test Article", "Body text")

        composeTestRule.setContent {
            ArticleScreen(article = testArticle, onBack = {})
        }

        composeTestRule.onNodeWithText("Test Article").assertIsDisplayed()
        composeTestRule.onNodeWithText("Body text").assertIsDisplayed()
    }

    @Test
    fun backButtonCallsOnBack() {
        var backCalled = false

        composeTestRule.setContent {
            ArticleScreen(article = testArticle, onBack = { backCalled = true })
        }

        composeTestRule.onNodeWithContentDescription("Navigate back").performClick()

        assertTrue(backCalled)
    }
}

Robolectric: Android APIs on the JVM

Robolectric shadows Android classes so you can test Android-dependent code without a device. Tests run in seconds instead of minutes:

// testImplementation("org.robolectric:robolectric:4.12.2")

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class NotificationHelperTest {

    @Test
    fun `notification has correct title and channel`() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        val helper = NotificationHelper(context)

        val notification = helper.buildArticleNotification("New Article!")

        assertEquals("New Article!", notification.extras.getString(Notification.EXTRA_TITLE))
        assertEquals("articles_channel", notification.channelId)
    }

    @Test
    fun `notification channel created on first use`() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        val helper = NotificationHelper(context)

        helper.createNotificationChannel()

        val manager = context.getSystemService(NotificationManager::class.java)
        val channel = manager.getNotificationChannel("articles_channel")
        assertNotNull(channel)
        assertEquals(NotificationManager.IMPORTANCE_DEFAULT, channel.importance)
    }
}

When to Use Each Tool

ToolDevice neededSpeedUse for
JUnit (pure)NoFast (~ms)Business logic, non-Android code
RobolectricNoMedium (~100ms)Android APIs (Context, Notifications, Intents) without device
Compose UI Test (local)NoMediumCompose composables in isolation
EspressoYes (emulator/device)Slow (~30s)Full screen flows, real user interactions

Common Espresso Patterns

// Scroll to an item in RecyclerView
onView(withId(R.id.recycler_view))
    .perform(RecyclerViewActions.scrollToPosition<RecyclerView.ViewHolder>(10))

// Check item in RecyclerView
onView(withId(R.id.recycler_view))
    .perform(RecyclerViewActions.actionOnItemAtPosition<RecyclerView.ViewHolder>(0, click()))

// Handle dialogs
onView(withText("Confirm")).inRoot(isDialog()).perform(click())

// Wait for idle (Espresso does this automatically with IdlingResources)
onView(withId(R.id.content)).check(matches(isDisplayed()))

Key Takeaways

RuleWhy
Prefer Robolectric over EspressoRuns without device; 10-100× faster
Use Espresso for full flowsLogin → home → detail navigations that span multiple activities
ActivityScenarioRuleModern replacement for ActivityTestRule; handles lifecycle correctly
composeTestRule.setContentTests composables in isolation — no need for a full Activity
Espresso IdlingResourceRegister async operations so Espresso waits for them before asserting

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Espresso & Robolectric | Android System Design | Android Engineers