Android UI Testing: Unit Tests, Integration Tests, and Espresso Explained
Android testing is layered. Your app has units of business logic, UI components that respond to state, and end-to-end flows that span multiple screens. Each layer needs a different kind of test.
This guide covers the full Android testing stack: unit tests, integration tests, and UI tests — when to use each, and how to write them.
The Android Testing Pyramid
┌───────────────────────┐
│ UI / E2E Tests │ (Espresso, UiAutomator)
│ (few, slow) │
├───────────────────────┤
│ Integration Tests │ (Robolectric, Flow tests)
│ (some, medium) │
├───────────────────────┤
│ Unit Tests │ (JUnit, Mockito, Turbine)
│ (many, fast) │
└───────────────────────┘Google's recommended split: 70% unit, 20% integration, 10% UI. In practice, "enough to catch bugs without being slow" is a better guide than rigid percentages.
Test Types in Android
| Type | Location | Runs on | Speed |
|---|---|---|---|
| Unit | src/test/ |
JVM | Very fast |
| Integration | src/test/ (Robolectric) |
JVM | Fast |
| Instrumented | src/androidTest/ |
Device/Emulator | Slow |
Unit tests run on the JVM. No Android framework involved.
Integration tests use Robolectric to simulate Android APIs on the JVM.
Instrumented tests run on a real device or emulator — they test actual Android behavior.
Setup
// app/build.gradle
dependencies {
// Unit + integration testing
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito:mockito-core:5.3.1'
testImplementation 'org.mockito.kotlin:mockito-kotlin:5.0.0'
testImplementation 'org.robolectric:robolectric:4.10.3'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
testImplementation 'app.cash.turbine:turbine:1.0.0'
testImplementation 'androidx.arch.core:core-testing:2.2.0'
// Instrumented (UI) testing
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1'
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.46.1'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.46.1'
}Layer 1: Unit Tests
Unit tests verify individual functions and classes in isolation. They run on the JVM with no Android dependencies.
// src/test/java/com/example/app/domain/CartTest.kt
class CartTest {
private lateinit var cart: Cart
@Before
fun setup() {
cart = Cart()
}
@Test
fun `adding item increases total`() {
cart.addItem(CartItem(id = "1", name = "Widget", price = 9.99, quantity = 2))
assertEquals(19.98, cart.total(), 0.01)
}
@Test
fun `removing item that doesn't exist does not throw`() {
assertDoesNotThrow {
cart.removeItem("nonexistent-id")
}
}
@Test
fun `applying discount reduces total correctly`() {
cart.addItem(CartItem(id = "1", name = "Widget", price = 100.0, quantity = 1))
cart.applyDiscount(PercentDiscount(percentage = 20))
assertEquals(80.0, cart.total(), 0.01)
}
}Testing ViewModels
ViewModels often depend on repositories. Mock the repository to test ViewModel logic:
// src/test/java/com/example/app/ui/HomeViewModelTest.kt
@ExtendWith(MockitoExtension::class)
class HomeViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Mock
private lateinit var userRepository: UserRepository
private lateinit var viewModel: HomeViewModel
@Before
fun setup() {
viewModel = HomeViewModel(userRepository)
}
@Test
fun `loading users updates state to success`() = runTest {
val fakeUsers = listOf(User("1", "Alice"), User("2", "Bob"))
whenever(userRepository.getUsers()).thenReturn(fakeUsers)
viewModel.loadUsers()
val state = viewModel.uiState.value
assertTrue(state is HomeUiState.Success)
assertEquals(2, (state as HomeUiState.Success).users.size)
}
@Test
fun `repository error updates state to error`() = runTest {
whenever(userRepository.getUsers()).thenThrow(RuntimeException("Network error"))
viewModel.loadUsers()
val state = viewModel.uiState.value
assertTrue(state is HomeUiState.Error)
}
}
// MainDispatcherRule for tests
class MainDispatcherRule(
val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
testDispatcher.cleanupTestCoroutines()
}
}Testing StateFlow with Turbine
Turbine makes testing StateFlow and Flow straightforward:
@Test
fun `search query updates results`() = runTest {
val viewModel = SearchViewModel(fakeSearchRepository)
viewModel.uiState.test {
// Initial state
assertEquals(SearchUiState.Empty, awaitItem())
// Trigger search
viewModel.search("android testing")
// Loading state
val loading = awaitItem()
assertTrue(loading is SearchUiState.Loading)
// Results
val results = awaitItem()
assertTrue(results is SearchUiState.Results)
assertTrue((results as SearchUiState.Results).items.isNotEmpty())
cancelAndIgnoreRemainingEvents()
}
}Layer 2: Integration Tests with Robolectric
Robolectric runs Android code on the JVM by providing shadow implementations of Android APIs. It's slower than pure unit tests but faster than instrumented tests.
Use Robolectric for:
- Fragment logic
- Room database queries
- SharedPreferences
- Intent handling
// src/test/java/com/example/app/ui/ProfileFragmentTest.kt
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [30])
class ProfileFragmentTest {
@Test
fun `fragment shows user name after loading`() {
val scenario = launchFragmentInContainer<ProfileFragment>(
themeResId = R.style.AppTheme
)
scenario.onFragment { fragment ->
// Interact with fragment using Espresso
onView(withId(R.id.tv_user_name))
.check(matches(withText("Alice")))
}
}
}Testing Room with an In-Memory Database
// src/test/java/com/example/app/data/UserDaoTest.kt
@RunWith(RobolectricTestRunner::class)
class UserDaoTest {
private lateinit var database: AppDatabase
private lateinit var userDao: UserDao
@Before
fun setup() {
database = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
AppDatabase::class.java
)
.allowMainThreadQueries()
.build()
userDao = database.userDao()
}
@After
fun teardown() {
database.close()
}
@Test
fun `insert and retrieve user`() = runTest {
val user = UserEntity(id = 1, name = "Alice", email = "alice@example.com")
userDao.insert(user)
val retrieved = userDao.getById(1)
assertNotNull(retrieved)
assertEquals("Alice", retrieved?.name)
}
@Test
fun `delete user removes from database`() = runTest {
val user = UserEntity(id = 1, name = "Alice", email = "alice@example.com")
userDao.insert(user)
userDao.delete(user)
val retrieved = userDao.getById(1)
assertNull(retrieved)
}
}Layer 3: UI Tests with Espresso
UI tests run on a real device or emulator. They're slow but test real behavior. Use them for your most critical user flows.
// src/androidTest/java/com/example/app/LoginFlowTest.kt
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class LoginFlowTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val activityRule = ActivityScenarioRule(MainActivity::class.java)
@Test
fun fullLoginFlow_navigatesToHomeAndShowsProfile() {
// Enter credentials
onView(withId(R.id.et_email))
.perform(typeText("user@example.com"), closeSoftKeyboard())
onView(withId(R.id.et_password))
.perform(typeText("password123"), closeSoftKeyboard())
// Login
onView(withId(R.id.btn_login))
.perform(click())
// Verify home screen
onView(withId(R.id.home_container))
.check(matches(isDisplayed()))
// Navigate to profile
onView(withId(R.id.nav_profile))
.perform(click())
onView(withId(R.id.tv_profile_email))
.check(matches(withText("user@example.com")))
}
}Testing Navigation
Use the Navigation Testing library for Fragment navigation assertions:
@Test
fun `clicking settings navigates to settings screen`() {
val navController = TestNavHostController(ApplicationProvider.getApplicationContext())
navController.setGraph(R.navigation.main_nav_graph)
val scenario = launchFragmentInContainer<HomeFragment>()
scenario.onFragment { fragment ->
Navigation.setViewNavController(fragment.requireView(), navController)
}
onView(withId(R.id.btn_settings)).perform(click())
assertEquals(R.id.settingsFragment, navController.currentDestination?.id)
}Testing Compose UI
If you're using Jetpack Compose, use the Compose testing library:
dependencies {
androidTestImplementation 'androidx.compose.ui:ui-test-junit4:1.5.1'
debugImplementation 'androidx.compose.ui:ui-test-manifest:1.5.1'
}@RunWith(AndroidJUnit4::class)
class LoginScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun loginScreen_displaysFormCorrectly() {
composeTestRule.setContent {
AppTheme {
LoginScreen(onLoginSuccess = {})
}
}
composeTestRule
.onNodeWithText("Email")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Password")
.assertIsDisplayed()
composeTestRule
.onNodeWithText("Login")
.assertIsDisplayed()
.assertHasClickAction()
}
@Test
fun loginScreen_successfulLogin_callsCallback() {
var loginCalled = false
composeTestRule.setContent {
AppTheme {
LoginScreen(onLoginSuccess = { loginCalled = true })
}
}
composeTestRule
.onNodeWithText("Email")
.performTextInput("user@example.com")
composeTestRule
.onNodeWithText("Password")
.performTextInput("password123")
composeTestRule
.onNodeWithText("Login")
.performClick()
assertTrue(loginCalled)
}
}Android UiAutomator for System-Level Tests
For tests that cross app boundaries (notifications, system settings, other apps), use UiAutomator:
// androidTest
class NotificationTest {
private val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
@Test
fun `app notification is actionable`() {
// Open notification shade
device.openNotification()
val notification = device.findObject(
UiSelector().textContains("New message from Alice")
)
assertTrue(notification.exists())
// Tap the notification
notification.click()
// Verify the app opened to the right screen
onView(withId(R.id.tv_conversation_title))
.check(matches(withText("Alice")))
}
}Running Tests
# Unit tests (JVM)
./gradlew <span class="hljs-built_in">test
<span class="hljs-comment"># Instrumented tests (device/emulator required)
./gradlew connectedAndroidTest
<span class="hljs-comment"># Specific test class
./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.example.app.LoginFlowTest
<span class="hljs-comment"># All checks (lint + unit + instrumented)
./gradlew check connectedAndroidTestCI Configuration
# .github/workflows/android-tests.yml
name: Android Tests
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
- run: ./gradlew test
instrumented-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '17'
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 30
arch: x86_64
script: ./gradlew connectedAndroidTestCoverage Reporting
Add JaCoCo for coverage:
// build.gradle
android {
buildTypes {
debug {
testCoverageEnabled true
}
}
}./gradlew createDebugCoverageReport
# Report at: app/build/reports/coverage/androidTest/debug/What to Test at Each Level
Unit test everything that has business logic: validation rules, data transformations, ViewModel state management, use cases, repository implementations.
Integration test your database layer, any complex Fragment logic, and navigation flows.
UI test your most important user journeys: registration, checkout, first-time onboarding. Keep these focused and minimal.
For coverage beyond your test environment — monitoring real users on production — HelpMeTest runs scheduled checks against your live app and alerts you when flows break. No emulator setup required.
Summary
- Unit tests (
src/test/): JUnit + Mockito + Turbine. Fast. Test logic, not Android. - Robolectric (
src/test/): Test Android components (Fragments, Room, SharedPreferences) on JVM. - Espresso (
src/androidTest/): Real device UI tests for critical flows. - Compose: Use
createComposeRule()andonNodeWithText()for Compose UI. - CI:
reactivecircus/android-emulator-runnerfor running instrumented tests in GitHub Actions.
Start with a strong unit test foundation. Add Robolectric for Android-specific integration points. Use Espresso sparingly for end-to-end flows that matter most.