Android Instrumented Testing with Espresso and Kotlin

Android Instrumented Testing with Espresso and Kotlin

Android has two types of tests: unit tests (JVM, fast) and instrumented tests (run on a device or emulator, access Android APIs). Instrumented tests are required for testing UI behavior, database operations, and anything that depends on the Android framework.

This guide covers instrumented tests using Espresso for View-based UI and the Compose Test library for Jetpack Compose.

Setup

// app/build.gradle.kts
android {
    defaultConfig {
        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }
}

dependencies {
    // Instrumented test dependencies
    androidTestImplementation("androidx.test.ext:junit:1.2.1")
    androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
    androidTestImplementation("androidx.test:runner:1.6.1")
    androidTestImplementation("androidx.test:rules:1.6.1")

    // Compose testing (if using Compose)
    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
    debugImplementation("androidx.compose.ui:ui-test-manifest")
}

Instrumented tests live in src/androidTest/kotlin/.

Espresso Basics

Espresso tests follow a three-step pattern: find a view, perform an action, check a result.

import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.*
import androidx.test.espresso.assertion.ViewAssertions.*
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class LoginActivityTest {

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

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

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

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

        onView(withId(R.id.dashboardTitle))
            .check(matches(isDisplayed()))
    }

    @Test
    fun emptyEmail_showsValidationError() {
        onView(withId(R.id.loginButton)).perform(click())

        onView(withId(R.id.emailInput))
            .check(matches(hasErrorText("Email is required")))
    }
}

View Matchers

Find views by different criteria:

onView(withId(R.id.myButton))              // by resource ID
onView(withText("Submit"))                  // by exact text
onView(withText(containsString("Subm")))    // text contains
onView(withContentDescription("close"))     // accessibility label
onView(withHint("Enter email"))             // hint text
onView(withTag("main_button"))              // view tag

// Combine matchers
onView(allOf(withId(R.id.item), withText("First")))
onView(allOf(isDisplayed(), withId(R.id.button)))

View Actions

perform(click())
perform(longClick())
perform(doubleClick())
perform(typeText("hello"))
perform(replaceText("new text"))
perform(clearText())
perform(closeSoftKeyboard())
perform(pressBack())
perform(scrollTo())              // scroll RecyclerView/ScrollView to view
perform(swipeLeft())
perform(swipeRight())

View Assertions

check(matches(isDisplayed()))
check(matches(isEnabled()))
check(matches(isChecked()))
check(matches(withText("Expected")))
check(matches(not(isDisplayed())))
check(doesNotExist())

RecyclerView Testing

import androidx.test.espresso.contrib.RecyclerViewActions.*

@Test
fun recyclerView_clicksItemAtPosition() {
    onView(withId(R.id.recyclerView))
        .perform(scrollToPosition<RecyclerView.ViewHolder>(5))
        .perform(actionOnItemAtPosition<RecyclerView.ViewHolder>(5, click()))

    onView(withId(R.id.detailTitle))
        .check(matches(isDisplayed()))
}

@Test
fun recyclerView_clicksItemWithText() {
    onView(withId(R.id.recyclerView))
        .perform(actionOnItem<RecyclerView.ViewHolder>(
            hasDescendant(withText("Widget")),
            click()
        ))
}

Intents Testing

Verify that the correct Intent is launched:

import androidx.test.espresso.intent.Intents.*
import androidx.test.espresso.intent.matcher.IntentMatchers.*
import androidx.test.espresso.intent.rule.IntentsRule

@RunWith(AndroidJUnit4::class)
class MainActivityTest {

    @get:Rule
    val intentsRule = IntentsRule()

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

    @Test
    fun shareButton_launchesShareIntent() {
        onView(withId(R.id.shareButton)).perform(click())

        intended(hasAction(Intent.ACTION_SEND))
        intended(hasType("text/plain"))
    }

    @Test
    fun openSettings_launchesSettingsActivity() {
        onView(withId(R.id.settingsButton)).perform(click())

        intended(hasComponent(SettingsActivity::class.java.name))
    }
}

Jetpack Compose Testing

Compose tests use the ComposeTestRule instead of Espresso:

import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.*

@RunWith(AndroidJUnit4::class)
class LoginScreenTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun loginButton_disabledWhenEmpty() {
        composeTestRule.setContent {
            LoginScreen(onLogin = {})
        }

        composeTestRule
            .onNodeWithText("Login")
            .assertIsNotEnabled()
    }

    @Test
    fun enterCredentials_enablesLoginButton() {
        composeTestRule.setContent {
            LoginScreen(onLogin = {})
        }

        composeTestRule
            .onNodeWithText("Email")
            .performTextInput("user@example.com")

        composeTestRule
            .onNodeWithText("Password")
            .performTextInput("password123")

        composeTestRule
            .onNodeWithText("Login")
            .assertIsEnabled()
    }
}

Compose Finders

onNodeWithText("Submit")
onNodeWithContentDescription("Close dialog")
onNodeWithTag("email_input")      // testTag modifier in composable
onAllNodesWithText("Item")        // multiple matching nodes
onNode(hasText("foo") and isEnabled())

Compose Actions

performClick()
performTextInput("text")
performScrollTo()
performTouchInput { swipeLeft() }

Activity and Fragment Scenarios

ActivityScenario and FragmentScenario give programmatic control:

@Test
fun activityScenario_rotatesAndPersistsState() {
    val scenario = ActivityScenario.launch(MainActivity::class.java)

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

    scenario.recreate()  // simulates rotation

    onView(withId(R.id.counterText))
        .check(matches(withText("1")))

    scenario.close()
}

Room Database Testing

Test Room DAOs with an in-memory database:

import androidx.room.Room
import androidx.test.core.app.ApplicationProvider

@RunWith(AndroidJUnit4::class)
class UserDaoTest {

    private lateinit var db: AppDatabase
    private lateinit var userDao: UserDao

    @Before
    fun setUp() {
        db = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            AppDatabase::class.java
        ).build()
        userDao = db.userDao()
    }

    @After
    fun tearDown() {
        db.close()
    }

    @Test
    fun insertAndRetrieveUser() = runTest {
        val user = User(id = 1, name = "Alice", email = "alice@example.com")
        userDao.insert(user)

        val retrieved = userDao.findById(1)
        assertEquals("Alice", retrieved?.name)
    }
}

CI with Firebase Test Lab

Run instrumented tests in CI without managing emulators:

# GitHub Actions with Firebase Test Lab
- name: Build APKs
  run: ./gradlew assembleDebug assembleDebugAndroidTest

- name: Run tests on Firebase
  run: |
    gcloud firebase test android run \
      --type instrumentation \
      --app app/build/outputs/apk/debug/app-debug.apk \
      --test app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
      --device model=Pixel6,version=33,locale=en,orientation=portrait

Production Monitoring

Instrumented tests validate UI behavior in development and CI. For production monitoring — verifying that your live Android backend and APIs work correctly 24/7 — HelpMeTest monitors endpoints continuously without requiring device setup.

Summary

  • Instrumented tests run on device/emulator; live in src/androidTest/
  • Espresso: onView()perform()check() for View-based UI
  • RecyclerView actions via espresso-contrib
  • Intents testing with IntentsRule and intended()
  • Compose: createComposeRule() + onNodeWith*() + perform*() / assert*()
  • Room DAOs: test with Room.inMemoryDatabaseBuilder
  • Firebase Test Lab for CI without managing emulators

Read more