1831 words
9 minutes
Android Testing Strategies: A Comprehensive Guide for Modern Architecture

Android Testing Strategies: A Comprehensive Guide for Modern Architecture#

Testing is the cornerstone of maintainable Android applications. This comprehensive guide explores testing strategies for modern Android architecture, covering unit testing, integration testing, and UI testing with real-world examples from Google’s Architecture Samples.

Testing Pyramid for Android#

The Three-Layer Testing Approach#

// Small Tests (70%) - Unit Tests
class TasksViewModelTest {
@Test
fun loadTasks_loading_showsLoading() {
// Arrange
val viewModel = TasksViewModel(fakeRepository)
// Act
viewModel.loadTasks()
// Assert
assertTrue(viewModel.uiState.value.isLoading)
}
}
// Medium Tests (20%) - Integration Tests
@MediumTest
class TasksRepositoryTest {
@Test
fun saveTask_retrievesTask() = runTest {
// Test repository with real database
val repository = DefaultTasksRepository(
tasksDao = database.taskDao(),
dispatcher = TestCoroutineDispatcher()
)
repository.saveTask(Task(id = "1", title = "Test Task"))
val result = repository.getTask("1")
assertEquals("Test Task", result.title)
}
}
// Large Tests (10%) - UI Tests
@LargeTest
class TasksScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun tasksScreen_showsTasksList() {
composeTestRule.setContent {
TasksScreen(/*...*/)
}
composeTestRule.onNodeWithText("Test Task")
.assertIsDisplayed()
}
}

Unit Testing ViewModels#

ViewModel Testing with Coroutines#

@ExtendWith(MockitoExtension::class)
class TaskDetailViewModelTest {
@Mock
lateinit var tasksRepository: TasksRepository
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private lateinit var viewModel: TaskDetailViewModel
@BeforeEach
fun setupViewModel() {
viewModel = TaskDetailViewModel(
taskId = "1",
tasksRepository = tasksRepository
)
}
@Test
fun loadTask_success_showsTask() = runTest {
// Arrange
val task = Task(id = "1", title = "Test Task", description = "Description")
whenever(tasksRepository.getTask("1")).thenReturn(task)
// Act
viewModel.loadTask()
// Assert
val uiState = viewModel.uiState.value
assertFalse(uiState.isLoading)
assertEquals(task, uiState.task)
assertNull(uiState.userMessage)
}
@Test
fun loadTask_failure_showsError() = runTest {
// Arrange
whenever(tasksRepository.getTask("1"))
.thenThrow(RuntimeException("Network error"))
// Act
viewModel.loadTask()
// Assert
val uiState = viewModel.uiState.value
assertFalse(uiState.isLoading)
assertNull(uiState.task)
assertNotNull(uiState.userMessage)
}
@Test
fun deleteTask_success_navigatesUp() = runTest {
// Arrange
val task = Task(id = "1", title = "Test Task")
viewModel.uiState.value = TaskDetailUiState(task = task)
// Act
viewModel.deleteTask()
// Assert
verify(tasksRepository).deleteTask("1")
assertTrue(viewModel.isTaskDeleted.value)
}
}

Testing State Management#

class TasksViewModelTest {
@Test
fun filterTasks_activeFilter_showsActiveTasks() = runTest {
// Arrange
val tasks = listOf(
Task(id = "1", title = "Active Task", isCompleted = false),
Task(id = "2", title = "Completed Task", isCompleted = true)
)
whenever(tasksRepository.getTasks()).thenReturn(tasks)
// Act
viewModel.setFiltering(TasksFilterType.ACTIVE_TASKS)
// Assert
val uiState = viewModel.uiState.value
assertEquals(1, uiState.items.size)
assertEquals("Active Task", uiState.items[0].title)
}
@Test
fun completeTask_updatesTaskAndRefreshes() = runTest {
// Arrange
val taskId = "1"
// Act
viewModel.completeTask(taskId, true)
// Assert
verify(tasksRepository).completeTask(taskId)
verify(tasksRepository).getTasks() // Refreshed after completion
}
}

Testing Repository Layer#

Repository Integration Testing#

@HiltAndroidTest
class DefaultTasksRepositoryTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Inject
@ApplicationContext
lateinit var context: Context
private lateinit var database: ToDoDatabase
private lateinit var repository: TasksRepository
@Before
fun setup() {
hiltRule.inject()
database = Room.inMemoryDatabaseBuilder(
context,
ToDoDatabase::class.java
)
.allowMainThreadQueries()
.build()
repository = DefaultTasksRepository(
tasksDao = database.taskDao(),
dispatcher = Dispatchers.IO
)
}
@After
fun tearDown() {
database.close()
}
@Test
fun saveTask_retrievesTask() = runTest {
// Arrange
val task = Task(
id = "1",
title = "Test Task",
description = "Test Description"
)
// Act
repository.saveTask(task)
val result = repository.getTask("1")
// Assert
assertEquals(task.title, result.title)
assertEquals(task.description, result.description)
}
@Test
fun deleteTask_removesTask() = runTest {
// Arrange
val task = Task(id = "1", title = "Test Task")
repository.saveTask(task)
// Act
repository.deleteTask("1")
// Assert
assertThrows<Exception> {
runBlocking { repository.getTask("1") }
}
}
@Test
fun getTasks_withFilter_returnsFilteredTasks() = runTest {
// Arrange
val activeTasks = listOf(
Task(id = "1", title = "Active 1", isCompleted = false),
Task(id = "2", title = "Active 2", isCompleted = false)
)
val completedTasks = listOf(
Task(id = "3", title = "Completed 1", isCompleted = true)
)
activeTasks.plus(completedTasks).forEach {
repository.saveTask(it)
}
// Act
val activeTasks = repository.getTasks(false)
val completedTasks = repository.getTasks(true)
// Assert
assertEquals(2, activeTasks.size)
assertEquals(1, completedTasks.size)
}
}

Testing Jetpack Compose UI#

Compose Testing Fundamentals#

@RunWith(AndroidJUnit4::class)
class TasksScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun tasksScreen_displaysTasksList() {
// Arrange
val tasks = listOf(
Task(id = "1", title = "Task 1", isCompleted = false),
Task(id = "2", title = "Task 2", isCompleted = true)
)
// Act
composeTestRule.setContent {
TasksScreen(
uiState = TasksUiState(items = tasks),
onAddTask = {},
onTaskClick = {},
onTaskCheckedChange = { _, _ -> }
)
}
// Assert
composeTestRule.onNodeWithText("Task 1").assertIsDisplayed()
composeTestRule.onNodeWithText("Task 2").assertIsDisplayed()
}
@Test
fun tasksScreen_clickAddTask_callsCallback() {
// Arrange
var addTaskCalled = false
// Act
composeTestRule.setContent {
TasksScreen(
uiState = TasksUiState(),
onAddTask = { addTaskCalled = true },
onTaskClick = {},
onTaskCheckedChange = { _, _ -> }
)
}
composeTestRule.onNodeWithContentDescription("Add task")
.performClick()
// Assert
assertTrue(addTaskCalled)
}
@Test
fun tasksScreen_toggleTaskCompletion_updatesState() {
// Arrange
val tasks = listOf(
Task(id = "1", title = "Task 1", isCompleted = false)
)
var toggledTaskId = ""
var toggledCompleted = false
// Act
composeTestRule.setContent {
TasksScreen(
uiState = TasksUiState(items = tasks),
onAddTask = {},
onTaskClick = {},
onTaskCheckedChange = { taskId, completed ->
toggledTaskId = taskId
toggledCompleted = completed
}
)
}
composeTestRule.onNodeWithText("Task 1")
.performClick()
// Assert
assertEquals("1", toggledTaskId)
assertTrue(toggledCompleted)
}
}

Testing Complex Compose Components#

class TaskItemTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun taskItem_completedTask_showsStrikethrough() {
// Arrange
val completedTask = Task(
id = "1",
title = "Completed Task",
isCompleted = true
)
// Act
composeTestRule.setContent {
TaskItem(
task = completedTask,
onTaskClick = {},
onCheckedChange = {}
)
}
// Assert
composeTestRule.onNodeWithText("Completed Task")
.assertHasStrikethroughText()
}
@Test
fun taskItem_longPress_showsContextMenu() {
// Arrange
val task = Task(id = "1", title = "Task")
// Act
composeTestRule.setContent {
TaskItem(
task = task,
onTaskClick = {},
onCheckedChange = {},
onLongClick = { /* Show context menu */ }
)
}
composeTestRule.onNodeWithText("Task")
.performTouchInput { longClick() }
// Assert context menu appears
composeTestRule.onNodeWithText("Delete")
.assertIsDisplayed()
}
}

Testing Navigation#

@HiltAndroidTest
class NavigationTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@get:Rule
val composeTestRule = createComposeRule()
private lateinit var navController: TestNavHostController
@Before
fun setupNavHost() {
hiltRule.inject()
composeTestRule.setContent {
navController = TestNavHostController(LocalContext.current)
navController.navigatorProvider.addNavigator(ComposeNavigator())
TodoNavHost(navController = navController)
}
}
@Test
fun navHost_startDestination_isTasksScreen() {
assertEquals(TodoDestinations.TASKS_ROUTE, navController.currentDestination?.route)
}
@Test
fun navHost_clickAddTask_navigatesToAddTask() {
// Click add task button
composeTestRule.onNodeWithContentDescription("Add task")
.performClick()
// Verify navigation to add/edit screen
assertEquals(
TodoDestinations.ADD_EDIT_TASK_ROUTE,
navController.currentDestination?.route
)
}
@Test
fun navHost_editTask_navigatesWithTaskId() {
// Navigate to task detail first
navController.navigate("${TodoDestinations.TASK_DETAIL_ROUTE}/1")
// Click edit button
composeTestRule.onNodeWithContentDescription("Edit task")
.performClick()
// Verify navigation to edit screen with task ID
assertTrue(
navController.currentDestination?.route?.contains("1") == true
)
}
}

Database Testing#

Room Database Testing#

@RunWith(AndroidJUnit4::class)
@SmallTest
class TasksDaoTest {
private lateinit var database: ToDoDatabase
private lateinit var tasksDao: TasksDao
@Before
fun setup() {
database = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
ToDoDatabase::class.java
)
.allowMainThreadQueries()
.build()
tasksDao = database.taskDao()
}
@After
fun tearDown() {
database.close()
}
@Test
fun insertTask_retrievesSameTask() = runTest {
// Arrange
val task = Task(
id = "1",
title = "Test Task",
description = "Test Description",
isCompleted = false
)
// Act
tasksDao.insertTask(task)
val retrieved = tasksDao.getTaskById("1")
// Assert
assertEquals(task.title, retrieved.title)
assertEquals(task.description, retrieved.description)
assertEquals(task.isCompleted, retrieved.isCompleted)
}
@Test
fun updateTask_modifiesExistingTask() = runTest {
// Arrange
val originalTask = Task(id = "1", title = "Original")
tasksDao.insertTask(originalTask)
// Act
val updatedTask = originalTask.copy(title = "Updated")
tasksDao.updateTask(updatedTask)
// Assert
val retrieved = tasksDao.getTaskById("1")
assertEquals("Updated", retrieved.title)
}
@Test
fun deleteCompletedTasks_onlyDeletesCompleted() = runTest {
// Arrange
val activeTasks = listOf(
Task(id = "1", title = "Active 1", isCompleted = false),
Task(id = "2", title = "Active 2", isCompleted = false)
)
val completedTasks = listOf(
Task(id = "3", title = "Completed 1", isCompleted = true),
Task(id = "4", title = "Completed 2", isCompleted = true)
)
activeTasks.plus(completedTasks).forEach {
tasksDao.insertTask(it)
}
// Act
tasksDao.deleteCompletedTasks()
// Assert
val remainingTasks = tasksDao.getTasks()
assertEquals(2, remainingTasks.size)
assertTrue(remainingTasks.all { !it.isCompleted })
}
}

Testing with Hilt Dependency Injection#

Test Doubles and Fake Repositories#

class FakeTasksRepository : TasksRepository {
private val tasks = mutableListOf<Task>()
private var shouldReturnError = false
fun setShouldReturnError(value: Boolean) {
shouldReturnError = value
}
override suspend fun getTasks(forceUpdate: Boolean): List<Task> {
if (shouldReturnError) {
throw Exception("Test exception")
}
return tasks.toList()
}
override suspend fun getTask(taskId: String): Task {
if (shouldReturnError) {
throw Exception("Test exception")
}
return tasks.find { it.id == taskId }
?: throw Exception("Task not found")
}
override suspend fun saveTask(task: Task) {
if (shouldReturnError) {
throw Exception("Test exception")
}
tasks.removeIf { it.id == task.id }
tasks.add(task)
}
override suspend fun deleteTask(taskId: String) {
if (shouldReturnError) {
throw Exception("Test exception")
}
tasks.removeIf { it.id == taskId }
}
override suspend fun completeTask(taskId: String) {
tasks.find { it.id == taskId }?.let { task ->
val index = tasks.indexOf(task)
tasks[index] = task.copy(isCompleted = true)
}
}
}
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [RepositoryModule::class]
)
object FakeRepositoryModule {
@Provides
@Singleton
fun provideTasksRepository(): TasksRepository = FakeTasksRepository()
}

End-to-End Testing with Hilt#

@HiltAndroidTest
@LargeTest
class TasksActivityTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val activityRule = ActivityScenarioRule(TasksActivity::class.java)
@Inject
lateinit var repository: TasksRepository
@Before
fun init() {
hiltRule.inject()
}
@Test
fun createTask_savesAndDisplaysTask() = runTest {
// Navigate to add task
onView(withId(R.id.add_task_fab)).perform(click())
// Fill task details
onView(withId(R.id.add_task_title_edit_text))
.perform(typeText("New Task"))
onView(withId(R.id.add_task_description_edit_text))
.perform(typeText("Task Description"))
// Save task
onView(withId(R.id.save_task_fab)).perform(click())
// Verify task appears in list
onView(withText("New Task")).check(matches(isDisplayed()))
// Verify task is saved in repository
val tasks = repository.getTasks()
assertTrue(tasks.any { it.title == "New Task" })
}
}

Performance Testing#

Testing Performance Characteristics#

class TasksPerformanceTest {
@get:Rule
val benchmarkRule = BenchmarkRule()
@Test
fun measureTaskListScrolling() {
benchmarkRule.measureRepeated {
// Generate large dataset
val tasks = (1..1000).map {
Task(id = it.toString(), title = "Task $it")
}
// Measure scrolling performance
composeTestRule.setContent {
TasksList(tasks = tasks)
}
composeTestRule.onNodeWithTag("TasksList")
.performScrollToIndex(500)
}
}
@Test
fun measureDatabaseOperations() = runTest {
benchmarkRule.measureRepeated {
runBlocking {
// Measure database insert performance
val tasks = (1..100).map {
Task(id = it.toString(), title = "Task $it")
}
tasks.forEach { repository.saveTask(it) }
// Measure query performance
repository.getTasks()
}
}
}
}

Testing Best Practices#

Test Organization#

class TasksViewModelTest {
// Test class setup
companion object {
private const val TASK_ID = "1"
private val TEST_TASK = Task(
id = TASK_ID,
title = "Test Task",
description = "Description"
)
}
// Group related tests
@Nested
@DisplayName("Loading Tasks")
inner class LoadingTasks {
@Test
fun `loading tasks shows loading state`() { /* ... */ }
@Test
fun `successful load shows tasks`() { /* ... */ }
@Test
fun `failed load shows error`() { /* ... */ }
}
@Nested
@DisplayName("Task Operations")
inner class TaskOperations {
@Test
fun `complete task updates repository`() { /* ... */ }
@Test
fun `delete task removes from list`() { /* ... */ }
}
}

Custom Test Rules and Extensions#

@ExperimentalCoroutinesApi
class MainDispatcherRule(
private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}
// Custom assertion extensions
fun ComposeTestRule.assertTaskIsDisplayed(taskTitle: String) {
onNodeWithText(taskTitle).assertIsDisplayed()
}
fun ComposeTestRule.assertTaskIsCompleted(taskTitle: String) {
onNodeWithText(taskTitle)
.assert(hasStrikethroughText())
}
// Test data builders
object TestTaskBuilder {
fun createTask(
id: String = "default-id",
title: String = "Default Task",
description: String = "Default Description",
isCompleted: Boolean = false
) = Task(
id = id,
title = title,
description = description,
isCompleted = isCompleted
)
fun createTasks(count: Int) = (1..count).map {
createTask(
id = it.toString(),
title = "Task $it"
)
}
}

Conclusion#

Comprehensive testing is essential for building robust Android applications. This guide covered:

  • Testing Pyramid: Balancing unit, integration, and UI tests
  • ViewModel Testing: Testing state management and coroutines
  • Repository Testing: Integration testing with Room database
  • Compose UI Testing: Testing declarative UI components
  • Navigation Testing: Verifying navigation flows
  • Dependency Injection: Using Hilt for testable architecture
  • Performance Testing: Measuring and optimizing performance

By implementing these testing strategies, you’ll build more reliable, maintainable Android applications that scale with confidence. Remember to write tests that are fast, reliable, and provide clear feedback when they fail.

The key is to start with a solid foundation of unit tests, add integration tests for critical paths, and use UI tests sparingly for end-to-end scenarios. This approach ensures comprehensive coverage while maintaining fast feedback loops during development.

Android Testing Strategies: A Comprehensive Guide for Modern Architecture
https://mranv.pages.dev/posts/android-testing-strategies-comprehensive-guide/
Author
Anubhav Gain
Published at
2025-08-05
License
CC BY-NC-SA 4.0