Every test type, every tool, every pattern. A thorough guide to building rock-solid Android applications through comprehensive testing strategies.
A strategic model that guides how to balance different types of tests for maximum confidence and minimum cost.
From unit to security — a complete breakdown of all Android testing disciplines.
Copy-paste ready test patterns for the most common Android testing scenarios.
@RunWith(MockitoJUnitRunner::class) class LoginViewModelTest { @get:Rule val mainDispatcherRule = MainDispatcherRule() @Mock private lateinit var authRepository: AuthRepository private lateinit var viewModel: LoginViewModel @Before fun setUp() { viewModel = LoginViewModel(authRepository) } @Test fun `login with valid credentials emits Success state`() = runTest { // Given whenever(authRepository.login("user@example.com", "password123")) .thenReturn(Result.success(User(id = "1", email = "user@example.com"))) // When viewModel.login("user@example.com", "password123") // Then assertThat(viewModel.uiState.value) .isInstanceOf(LoginUiState.Success::class.java) } @Test fun `login with empty email shows validation error`() = runTest { // When viewModel.login("", "password123") // Then val state = viewModel.uiState.value as LoginUiState.Error assertThat(state.message).isEqualTo("Email cannot be empty") verifyNoInteractions(authRepository) } @Test fun `login failure shows error state`() = runTest { // Given whenever(authRepository.login(any(), any())) .thenReturn(Result.failure(IOException("Network error"))) // When viewModel.login("user@example.com", "wrong") // Then assertThat(viewModel.uiState.value) .isInstanceOf(LoginUiState.Error::class.java) } }
@RunWith(AndroidJUnit4::class) @SmallTest class UserDaoTest { private lateinit var database: AppDatabase private lateinit var userDao: UserDao @Before fun createDb() { val context = ApplicationProvider.getApplicationContext<Context>() database = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) .allowMainThreadQueries() .build() userDao = database.userDao() } @After fun closeDb() = database.close() @Test fun insertAndRetrieveUser() = runTest { val user = UserEntity(id = "1", name = "Alice", email = "alice@test.com") userDao.insert(user) val retrieved = userDao.getUserById("1") assertThat(retrieved).isEqualTo(user) } @Test fun updateUserEmitsNewValue() = runTest { val user = UserEntity(id = "2", name = "Bob", email = "bob@test.com") userDao.insert(user) userDao.update(user.copy(name = "Bobby")) userDao.observeUser("2").test { assertThat(awaitItem().name).isEqualTo("Bobby") cancelAndConsumeRemainingEvents() } } @Test fun deleteUserRemovesFromDb() = runTest { val user = UserEntity(id = "3", name = "Carol", email = "carol@test.com") userDao.insert(user) userDao.delete(user) assertThat(userDao.getUserById("3")).isNull() } }
@RunWith(AndroidJUnit4::class) @LargeTest class LoginActivityTest { @get:Rule val activityRule = ActivityScenarioRule(LoginActivity::class.java) @Test fun successfulLogin_navigatesToHome() { // Type credentials onView(withId(R.id.emailField)) .perform(typeText("user@test.com"), closeSoftKeyboard()) onView(withId(R.id.passwordField)) .perform(typeText("password123"), closeSoftKeyboard()) // Click login onView(withId(R.id.loginButton)) .perform(click()) // Verify navigation to HomeActivity intended(hasComponent(HomeActivity::class.java.name)) } @Test fun emptyEmail_showsErrorMessage() { onView(withId(R.id.loginButton)).perform(click()) onView(withText("Email cannot be empty")) .check(matches(isDisplayed())) } @Test fun recyclerItem_click_opensDetail() { onView(withId(R.id.recyclerView)) .perform(RecyclerViewActions .actionOnItemAtPosition<UserAdapter.ViewHolder>(0, click())) onView(withId(R.id.detailContainer)) .check(matches(isDisplayed())) } }
class ProfileScreenTest { @get:Rule val composeTestRule = createComposeRule() @Test fun profileScreen_displaysUserName() { composeTestRule.setContent { ProfileScreen(user = User(name = "Alice", bio = "Android Developer")) } composeTestRule.onNodeWithText("Alice").assertIsDisplayed() composeTestRule.onNodeWithText("Android Developer").assertIsDisplayed() } @Test fun editButton_click_togglesEditMode() { composeTestRule.setContent { ProfileScreen(user = User(name = "Alice")) } composeTestRule.onNodeWithContentDescription("Edit profile").performClick() composeTestRule.onNodeWithTag("nameInput").assertIsDisplayed() composeTestRule.onNodeWithTag("nameInput").assertTextContains("Alice") } @Test fun loadingState_showsProgressIndicator() { composeTestRule.setContent { ProfileScreen(uiState = ProfileUiState.Loading) } composeTestRule.onNodeWithTag("loadingIndicator").assertIsDisplayed() composeTestRule.onNodeWithText("Alice").assertDoesNotExist() } @Test fun semanticsCheck_allImagesHaveContentDescription() { composeTestRule.setContent { ProfileScreen(user = User(avatarUrl = "https://...")) } composeTestRule .onAllNodesWithContentDescription("Profile avatar") .assertAll(isDisplayed()) } }
class FeedViewModelTest { @get:Rule val mainDispatcherRule = MainDispatcherRule() private val feedRepository = mockk<FeedRepository>() private lateinit var viewModel: FeedViewModel @Before fun setUp() { viewModel = FeedViewModel(feedRepository) } @Test fun `feed items flow emits loading then content`() = runTest { val items = listOf(FeedItem("1"), FeedItem("2")) coEvery { feedRepository.observeFeed() } returns flowOf(items) viewModel.feedItems.test { // Initial loading state assertThat(awaitItem()).isInstanceOf(FeedUiState.Loading::class.java) // Content state val content = awaitItem() as FeedUiState.Content assertThat(content.items).hasSize(2) cancelAndConsumeRemainingEvents() } } @Test fun `refresh triggers new fetch`() = runTest { coEvery { feedRepository.refresh() } returns Unit viewModel.refresh() coVerify(exactly = 1) { feedRepository.refresh() } } }
@HiltAndroidTest @RunWith(AndroidJUnit4::class) class MainActivityHiltTest { @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this) @get:Rule(order = 1) val activityRule = ActivityScenarioRule(MainActivity::class.java) // Replace real dep with a fake @BindValue @JvmField val authRepository: AuthRepository = mockk(relaxed = true) @Before fun init() = hiltRule.inject() @Test fun loggedOutUser_seesLoginScreen() { every { authRepository.isLoggedIn() } returns false onView(withId(R.id.loginScreen)) .check(matches(isDisplayed())) } @Test fun loggedInUser_seesHomeScreen() { every { authRepository.isLoggedIn() } returns true onView(withId(R.id.homeScreen)) .check(matches(isDisplayed())) } }
@RunWith(AndroidJUnit4::class) class AppStartupBenchmark { @get:Rule val benchmarkRule = MacrobenchmarkRule() @Test fun startupCold() = benchmarkRule.measureRepeated( packageName = "com.example.myapp", metrics = listOf(StartupTimingMetric()), iterations = 5, startupMode = StartupMode.COLD, ) { pressHome() startActivityAndWait() } @Test fun scrollFeedJankMetrics() = benchmarkRule.measureRepeated( packageName = "com.example.myapp", metrics = listOf(FrameTimingMetric()), iterations = 3, startupMode = StartupMode.WARM, setupBlock = { startActivityAndWait() } ) { val device = device() val recycler = device.findObject(By.res("com.example.myapp:id/feedList")) recycler.setGestureMargin(device.displayWidth / 5) recycler.fling(Direction.DOWN) } }
Patterns that separate a solid test suite from a flimsy one.
`given X when Y then Z` or `action_condition_result`. The test name should read as a specification and be understandable without reading the body.A well-structured pipeline runs your full suite on every commit, catching regressions before they reach production.
Patterns for large, production-scale Android codebases.
runTest to wrap coroutine test bodies.test { } for Flow emissionsThe definitive table of every major Android testing tool and when to use it.
| Tool | Category | Runs On | Primary Use |
|---|---|---|---|
| JUnit4 / JUnit5 | Unit | JVM | Base test runner, assertions, test rules for all local tests |
| Mockito / MockK | Unit | JVM | Mock/stub dependencies; MockK is idiomatic Kotlin |
| Truth | Unit | JVM | Fluent assertions that produce readable failure messages |
| Turbine | Unit | JVM | Test Kotlin Flows — await emissions, check errors |
| Kotest | Unit | JVM | Property-based, behavior-driven test framework for Kotlin |
| AndroidJUnit4 | Integration | Device | Instrumented test runner for on-device tests |
| Room In-Memory | Integration | Device | Test DAO queries with an in-memory Room database |
| MockWebServer | Integration | JVM/Device | Intercept HTTP calls and return fixture JSON responses |
| WorkManager Test | Integration | Device | Test Worker logic and chaining with TestListenableWorkerBuilder |
| Espresso | UI | Device | Synchronised View UI testing — clicks, scrolls, checks |
| Compose Testing | UI | Device / JVM | Semantics-based testing for Jetpack Compose screens |
| Barista | UI | Device | Espresso wrapper with more readable API and flakiness handling |
| Kaspresso | UI | Device | Kotlin DSL over Espresso + UI Automator with built-in flakiness handling |
| UI Automator | E2E | Device | Cross-app interactions, system UI, notifications, settings |
| Maestro | E2E | Device | YAML-based E2E flows, no code required, fast iteration |
| Appium | E2E | Device | Cross-platform E2E testing (Android + iOS from same test) |
| Macrobenchmark | Perf | Device | Measure startup time, scrolling, and transitions at macro level |
| Microbenchmark | Perf | Device | Benchmarks for individual functions — allocation, compute speed |
| LeakCanary | Perf | Device | Automatic memory leak detection in debug builds |
| Paparazzi | Snapshot | JVM | Screenshot tests for Compose/Views without a device — fast CI |
| Roborazzi | Snapshot | JVM | Robolectric-based screenshot tests with rich diff output |
| Firebase Test Lab | E2E/Cloud | Cloud Devices | Run tests on real device matrix in Google Cloud — CI integration |