Ritesh Singh logo
Ritesh Sohlot
Ritesh Sohlot

Android Testing Strategies: Complete Guide to Unit, Integration, and UI Testing

Learn comprehensive testing strategies for Android applications, including unit testing, integration testing, UI testing, and best practices for maintaining code quality.

Published
Reading Time
8 min read
Views
0 views

Testing is a crucial aspect of Android development that ensures code quality, reliability, and maintainability. A comprehensive testing strategy includes unit tests, integration tests, and UI tests. This guide will walk you through implementing effective testing strategies for your Android applications.

Testing Pyramid

The testing pyramid is a metaphor that describes the ideal distribution of tests in your application:

  • Unit Tests (70%): Fast, focused tests for individual components
  • Integration Tests (20%): Tests for component interactions
  • UI Tests (10%): End-to-end tests for user workflows

Benefits of Testing

  • Bug Prevention: Catch issues early in development
  • Refactoring Confidence: Safe to modify code with test coverage
  • Documentation: Tests serve as living documentation
  • Design Feedback: Tests help identify design issues
  • Regression Prevention: Ensure new changes don't break existing functionality

Unit Testing

Unit tests verify that individual components work correctly in isolation.

Setting Up Unit Tests

dependencies {
    // Unit testing
    testImplementation "junit:junit:4.13.2"
    testImplementation "org.mockito:mockito-core:5.3.1"
    testImplementation "org.mockito:mockito-inline:5.2.0"
    testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3"
    testImplementation "app.cash.turbine:turbine:1.0.0"
    testImplementation "io.mockk:mockk:1.13.8"
}

Testing ViewModels

@RunWith(MockitoJUnitRunner::class)
class UserViewModelTest {
    
    @Mock
    private lateinit var userRepository: UserRepository
    
    private lateinit var viewModel: UserViewModel
    
    @Before
    fun setup() {
        viewModel = UserViewModel(userRepository)
    }
    
    @Test
    fun `loadUsers should update uiState to success`() = runTest {
        // Given
        val users = listOf(User(1, "John", "john@example.com"))
        whenever(userRepository.getUsers()).thenReturn(users)
        
        // When
        viewModel.loadUsers()
        
        // Then
        val uiState = viewModel.uiState.value
        assertThat(uiState).isInstanceOf(UserUiState.Success::class.java)
        assertThat((uiState as UserUiState.Success).users).isEqualTo(users)
    }
    
    @Test
    fun `loadUsers should update uiState to error on failure`() = runTest {
        // Given
        val errorMessage = "Network error"
        whenever(userRepository.getUsers()).thenThrow(Exception(errorMessage))
        
        // When
        viewModel.loadUsers()
        
        // Then
        val uiState = viewModel.uiState.value
        assertThat(uiState).isInstanceOf(UserUiState.Error::class.java)
        assertThat((uiState as UserUiState.Error).message).isEqualTo(errorMessage)
    }
    
    @Test
    fun `loadUsers should emit loading state first`() = runTest {
        // Given
        val users = listOf(User(1, "John", "john@example.com"))
        whenever(userRepository.getUsers()).thenReturn(users)
        
        // When
        val uiStates = mutableListOf<UserUiState>()
        val job = launch {
            viewModel.uiState.toList(uiStates)
        }
        viewModel.loadUsers()
        job.cancel()
        
        // Then
        assertThat(uiStates).hasSize(2)
        assertThat(uiStates[0]).isInstanceOf(UserUiState.Loading::class.java)
        assertThat(uiStates[1]).isInstanceOf(UserUiState.Success::class.java)
    }
}

Testing Repositories

@RunWith(MockitoJUnitRunner::class)
class UserRepositoryTest {
    
    @Mock
    private lateinit var apiService: ApiService
    
    @Mock
    private lateinit var userDao: UserDao
    
    private lateinit var repository: UserRepository
    
    @Before
    fun setup() {
        repository = UserRepository(apiService, userDao)
    }
    
    @Test
    fun `getUsers should return network data and cache it`() = runTest {
        // Given
        val networkUsers = listOf(User(1, "John", "john@example.com"))
        whenever(apiService.getUsers()).thenReturn(networkUsers)
        
        // When
        val result = repository.getUsers()
        
        // Then
        assertThat(result).isEqualTo(networkUsers)
        verify(userDao).insertUsers(networkUsers)
    }
    
    @Test
    fun `getUsers should return cached data on network error`() = runTest {
        // Given
        val cachedUsers = listOf(User(1, "John", "john@example.com"))
        whenever(apiService.getUsers()).thenThrow(Exception("Network error"))
        whenever(userDao.getAllUsers()).thenReturn(cachedUsers)
        
        // When
        val result = repository.getUsers()
        
        // Then
        assertThat(result).isEqualTo(cachedUsers)
    }
}

Testing Coroutines

@RunWith(MockitoJUnitRunner::class)
class CoroutineTest {
    
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()
    
    @Test
    fun `test coroutine execution`() = runTest {
        // Given
        val testDispatcher = StandardTestDispatcher()
        Dispatchers.setMain(testDispatcher)
        
        // When
        val result = async {
            delay(1000)
            "Test Result"
        }.await()
        
        // Then
        assertThat(result).isEqualTo("Test Result")
    }
}

class MainDispatcherRule : TestWatcher() {
    private val testDispatcher = StandardTestDispatcher()
    
    override fun starting(description: Description) {
        super.starting(description)
        Dispatchers.setMain(testDispatcher)
    }
    
    override fun finished(description: Description) {
        super.finished(description)
        Dispatchers.resetMain()
    }
}

Integration Testing

Integration tests verify that multiple components work together correctly.

Testing Room Database

@RunWith(AndroidJUnit4::class)
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
        ).build()
        userDao = database.userDao()
    }
    
    @After
    fun closeDb() {
        database.close()
    }
    
    @Test
    fun insertAndGetUser() = runTest {
        // Given
        val user = User(name = "John", email = "john@example.com")
        
        // When
        val userId = userDao.insertUser(user)
        val retrievedUser = userDao.getUserById(userId.toInt())
        
        // Then
        assertThat(retrievedUser).isNotNull()
        assertThat(retrievedUser?.name).isEqualTo("John")
        assertThat(retrievedUser?.email).isEqualTo("john@example.com")
    }
    
    @Test
    fun getAllUsers() = runTest {
        // Given
        val user1 = User(name = "John", email = "john@example.com")
        val user2 = User(name = "Jane", email = "jane@example.com")
        
        // When
        userDao.insertUser(user1)
        userDao.insertUser(user2)
        val users = userDao.getAllUsers()
        
        // Then
        assertThat(users).hasSize(2)
        assertThat(users).extracting("name").containsExactly("John", "Jane")
    }
}

Testing Retrofit API

@RunWith(AndroidJUnit4::class)
class ApiServiceTest {
    
    private lateinit var mockWebServer: MockWebServer
    private lateinit var apiService: ApiService
    
    @Before
    fun setup() {
        mockWebServer = MockWebServer()
        mockWebServer.start()
        
        val retrofit = Retrofit.Builder()
            .baseUrl(mockWebServer.url("/"))
            .addConverterFactory(GsonConverterFactory.create())
            .build()
        
        apiService = retrofit.create(ApiService::class.java)
    }
    
    @After
    fun tearDown() {
        mockWebServer.shutdown()
    }
    
    @Test
    fun getUsers_shouldReturnUserList() = runTest {
        // Given
        val mockResponse = """
            [
                {"id": 1, "name": "John", "email": "john@example.com"},
                {"id": 2, "name": "Jane", "email": "jane@example.com"}
            ]
        """.trimIndent()
        
        mockWebServer.enqueue(
            MockResponse()
                .setResponseCode(200)
                .setBody(mockResponse)
        )
        
        // When
        val users = apiService.getUsers()
        
        // Then
        assertThat(users).hasSize(2)
        assertThat(users[0].name).isEqualTo("John")
        assertThat(users[1].name).isEqualTo("Jane")
    }
    
    @Test
    fun getUsers_shouldHandleError() = runTest {
        // Given
        mockWebServer.enqueue(
            MockResponse().setResponseCode(500)
        )
        
        // When & Then
        assertThrows(Exception::class.java) {
            apiService.getUsers()
        }
    }
}

UI Testing

UI tests verify that the user interface works correctly and user workflows function as expected.

Setting Up UI Tests

dependencies {
    // UI testing
    androidTestImplementation "androidx.test.ext:junit:1.1.5"
    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:rules:1.5.0"
    androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.5.4"
    androidTestImplementation "androidx.compose.ui:ui-test-manifest:1.5.4"
}

Testing Activities

@RunWith(AndroidJUnit4::class)
class MainActivityTest {
    
    @get:Rule
    val activityRule = ActivityScenarioRule(MainActivity::class.java)
    
    @Test
    fun shouldDisplayUserList() {
        // Given
        val expectedUserNames = listOf("John", "Jane", "Bob")
        
        // When & Then
        expectedUserNames.forEach { name ->
            onView(withText(name)).check(matches(isDisplayed()))
        }
    }
    
    @Test
    fun shouldNavigateToUserDetail() {
        // Given
        val userName = "John"
        
        // When
        onView(withText(userName)).perform(click())
        
        // Then
        onView(withId(R.id.user_detail_container))
            .check(matches(isDisplayed()))
    }
    
    @Test
    fun shouldShowLoadingState() {
        // Given
        val loadingText = "Loading..."
        
        // When & Then
        onView(withText(loadingText)).check(matches(isDisplayed()))
    }
    
    @Test
    fun shouldShowErrorState() {
        // Given
        val errorText = "Error loading users"
        
        // When & Then
        onView(withText(errorText)).check(matches(isDisplayed()))
        onView(withId(R.id.retry_button)).check(matches(isDisplayed()))
    }
}

Testing Compose UI

@RunWith(AndroidJUnit4::class)
class UserScreenTest {
    
    @get:Rule
    val composeTestRule = createComposeRule()
    
    @Test
    fun shouldDisplayUserList() {
        // Given
        val users = listOf(
            User(1, "John", "john@example.com"),
            User(2, "Jane", "jane@example.com")
        )
        
        // When
        composeTestRule.setContent {
            UserScreen(users = users)
        }
        
        // Then
        composeTestRule.onNodeWithText("John").assertIsDisplayed()
        composeTestRule.onNodeWithText("Jane").assertIsDisplayed()
    }
    
    @Test
    fun shouldShowLoadingState() {
        // When
        composeTestRule.setContent {
            UserScreen(uiState = UserUiState.Loading)
        }
        
        // Then
        composeTestRule.onNodeWithTag("loading_indicator")
            .assertIsDisplayed()
    }
    
    @Test
    fun shouldShowErrorState() {
        // Given
        val errorMessage = "Failed to load users"
        
        // When
        composeTestRule.setContent {
            UserScreen(uiState = UserUiState.Error(errorMessage))
        }
        
        // Then
        composeTestRule.onNodeWithText(errorMessage).assertIsDisplayed()
        composeTestRule.onNodeWithText("Retry").assertIsDisplayed()
    }
    
    @Test
    fun shouldHandleUserClick() {
        // Given
        val users = listOf(User(1, "John", "john@example.com"))
        var clickedUserId: Int? = null
        
        // When
        composeTestRule.setContent {
            UserScreen(
                users = users,
                onUserClick = { userId -> clickedUserId = userId }
            )
        }
        
        composeTestRule.onNodeWithText("John").performClick()
        
        // Then
        assertThat(clickedUserId).isEqualTo(1)
    }
}

Testing Navigation

@RunWith(AndroidJUnit4::class)
class NavigationTest {
    
    @get:Rule
    val composeTestRule = createComposeRule()
    
    @Test
    fun shouldNavigateToUserDetail() {
        // Given
        val users = listOf(User(1, "John", "john@example.com"))
        
        // When
        composeTestRule.setContent {
            AppNavigation()
        }
        
        // Then
        composeTestRule.onNodeWithText("John").performClick()
        composeTestRule.onNodeWithText("User Details").assertIsDisplayed()
    }
}

Test Doubles

Mocks

@RunWith(MockitoJUnitRunner::class)
class MockExampleTest {
    
    @Mock
    private lateinit var userRepository: UserRepository
    
    @Mock
    private lateinit var apiService: ApiService
    
    @Before
    fun setup() {
        // Setup mocks
        whenever(userRepository.getUsers()).thenReturn(emptyList())
        whenever(apiService.getUsers()).thenReturn(emptyList())
    }
    
    @Test
    fun testWithMocks() {
        // Test implementation
    }
}

Stubs

class StubUserRepository : UserRepository {
    override suspend fun getUsers(): List<User> {
        return listOf(
            User(1, "Stub User", "stub@example.com")
        )
    }
    
    override suspend fun getUserById(id: Int): User? {
        return User(id, "Stub User", "stub@example.com")
    }
}

Fakes

class FakeUserRepository : UserRepository {
    private val users = mutableListOf<User>()
    
    override suspend fun getUsers(): List<User> {
        return users.toList()
    }
    
    override suspend fun getUserById(id: Int): User? {
        return users.find { it.id == id }
    }
    
    override suspend fun insertUser(user: User): Long {
        users.add(user)
        return user.id.toLong()
    }
}

Test Data Builders

object TestData {
    fun createUser(
        id: Int = 1,
        name: String = "Test User",
        email: String = "test@example.com"
    ) = User(id, name, email)
    
    fun createUserList(count: Int = 3): List<User> {
        return (1..count).map { createUser(id = it) }
    }
    
    fun createPost(
        id: Int = 1,
        title: String = "Test Post",
        content: String = "Test content",
        authorId: Int = 1
    ) = Post(id, title, content, authorId)
}

Test Coverage

Coverage Configuration

android {
    buildTypes {
        debug {
            testCoverageEnabled true
        }
    }
}

// Generate coverage report
task jacocoTestReport(type: JacocoReport, dependsOn: ['testDebugUnitTest']) {
    reports {
        xml.required = true
        html.required = true
    }
    
    def fileFilter = [
        '**/R.class',
        '**/R$*.class',
        '**/BuildConfig.*',
        '**/Manifest*.*',
        '**/*Test*.*',
        'android/**/*.*'
    ]
    
    def debugTree = fileTree(dir: "$buildDir/intermediates/javac/debug", excludes: fileFilter)
    def mainSrc = "$project.projectDir/src/main/java"
    
    sourceDirectories.from = files([mainSrc])
    classDirectories.from = files([debugTree])
    executionData.from = files("$buildDir/jacoco/testDebugUnitTest.exec")
}

Best Practices

1. Test Structure (AAA Pattern)

  • Arrange: Set up test data and conditions
  • Act: Execute the method being tested
  • Assert: Verify the expected outcomes

2. Test Naming

  • Use descriptive test names
  • Follow the pattern: methodName_shouldDoSomething_whenCondition
  • Make test names readable and self-documenting

3. Test Isolation

  • Each test should be independent
  • Avoid test dependencies
  • Clean up after each test

4. Test Data Management

  • Use test data builders
  • Create reusable test data
  • Keep test data simple and focused

5. Mocking Strategy

  • Mock external dependencies
  • Don't mock the class under test
  • Use appropriate test doubles

6. UI Testing

  • Test user workflows, not implementation details
  • Focus on user interactions
  • Test error states and edge cases

7. Performance Testing

  • Keep tests fast
  • Use appropriate test scopes
  • Avoid slow operations in unit tests

Common Testing Patterns

Testing State Changes

@Test
fun `should update state when loading`() = runTest {
    // Given
    val initialState = UserUiState.Success(emptyList())
    
    // When
    viewModel.loadUsers()
    
    // Then
    val states = mutableListOf<UserUiState>()
    viewModel.uiState.toList(states)
    
    assertThat(states).contains(UserUiState.Loading)
}

Testing Events

@Test
fun `should emit navigation event on user click`() = runTest {
    // Given
    val user = User(1, "John", "john@example.com")
    
    // When
    viewModel.onUserClick(user)
    
    // Then
    val events = mutableListOf<UserEvent>()
    viewModel.events.toList(events)
    
    assertThat(events).contains(UserEvent.NavigateToDetail(user.id))
}

Testing Coroutines

@Test
fun `should handle coroutine cancellation`() = runTest {
    // Given
    val job = viewModel.loadUsers()
    
    // When
    job.cancel()
    
    // Then
    assertThat(job.isCancelled).isTrue()
}

Conclusion

A comprehensive testing strategy is essential for building reliable Android applications. By implementing unit tests, integration tests, and UI tests, you can:

  • Catch bugs early in the development process
  • Refactor confidently with test coverage
  • Document behavior through tests
  • Improve design by identifying issues during testing
  • Prevent regressions when making changes

Remember to:

  • Start with unit tests for business logic
  • Add integration tests for component interactions
  • Include UI tests for critical user workflows
  • Maintain test quality with good practices
  • Keep tests fast and focused

By following these testing strategies, you can build robust, maintainable Android applications that users can rely on.

Article completed • Thank you for reading!