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 Testsclass 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@MediumTestclass 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@LargeTestclass 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
@HiltAndroidTestclass 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
Navigation Testing with Compose
@HiltAndroidTestclass 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)@SmallTestclass 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@LargeTestclass 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
@ExperimentalCoroutinesApiclass MainDispatcherRule( private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()) : TestWatcher() {
override fun starting(description: Description) { Dispatchers.setMain(testDispatcher) }
override fun finished(description: Description) { Dispatchers.resetMain() }}
// Custom assertion extensionsfun ComposeTestRule.assertTaskIsDisplayed(taskTitle: String) { onNodeWithText(taskTitle).assertIsDisplayed()}
fun ComposeTestRule.assertTaskIsCompleted(taskTitle: String) { onNodeWithText(taskTitle) .assert(hasStrikethroughText())}
// Test data buildersobject 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.