Android UI Testing: Unit Tests, Integration Tests, and Espresso Explained

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 connectedAndroidTest

CI 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 connectedAndroidTest

Coverage 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() and onNodeWithText() for Compose UI.
  • CI: reactivecircus/android-emulator-runner for 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.

Read more