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.
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.