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=portraitProduction 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
IntentsRuleandintended() - Compose:
createComposeRule()+onNodeWith*()+perform*()/assert*() - Room DAOs: test with
Room.inMemoryDatabaseBuilder - Firebase Test Lab for CI without managing emulators