Android Testing Best Practices
Key Concepts
Android testing follows the testing honeycomb model, which prioritizes integration tests (50-60%) over unit tests (30-40%) and UI tests (<10%) because integration tests provide the best balance of confidence, speed, and maintenance cost. Unit tests verify individual components (ViewModels, use cases, mappers) in isolation using mocks, executing in milliseconds without Android framework dependencies. Integration tests verify interactions between layers (Repository + DAO + API, ViewModel + UseCase + Repository) using real implementations where practical (in-memory Room database, Robolectric for Android framework), catching issues that unit tests miss while remaining fast enough for continuous integration. UI tests verify end-to-end user flows with real Android instrumentation, running slowly but catching integration issues between UI and business logic. This guide demonstrates testing strategies for each layer of Clean Architecture, with special emphasis on coroutine testing, Flow verification with Turbine, and Compose UI testing. For architectural context, see Android Architecture, and for Clean Architecture layer organization, refer to the layer separation principles in that guide.
Overview
This guide covers Android testing best practices using JUnit 5 for unit tests, MockK for mocking, Espresso for UI testing, Compose UI testing for Jetpack Compose, Turbine for Flow testing, and screenshot testing. It complements Android Overview with comprehensive testing strategies for complex Android applications.
Core Principles
- Testing Honeycomb: Integration tests (50-60%), unit tests (30-40%), UI tests (<10%)
- Test Real Android Components: Use Robolectric for faster unit tests with Android dependencies
- MockK Over Mockito: Kotlin-first mocking with coroutines support
- Turbine for Flows: Test StateFlow and Flow emissions with time-based assertions
- Compose Test Semantics: Test Compose UI by semantics, not implementation details
- Screenshot Testing: Catch visual regressions with automated screenshot comparison
- Offline-First Testing: Verify Room database behavior and network fallbacks
Testing Strategy Overview
Unit Testing Setup
Dependencies
// build.gradle (Module :app)
dependencies {
// JUnit 5
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.1'
testRuntimeOnly 'org.junit.vintage:junit-vintage-engine:5.10.1'
// MockK
testImplementation 'io.mockk:mockk:1.13.8'
testImplementation 'io.mockk:mockk-android:1.13.8'
// Kotlin Coroutines Test
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
// Turbine for Flow testing
testImplementation 'app.cash.turbine:turbine:1.0.0'
// Truth for assertions
testImplementation 'com.google.truth:truth:1.1.5'
// Robolectric for Android framework
testImplementation 'org.robolectric:robolectric:4.11.1'
// AndroidX Test (for unit tests)
testImplementation 'androidx.test:core-ktx:1.5.0'
testImplementation 'androidx.test.ext:junit-ktx:1.1.5'
testImplementation 'androidx.arch.core:core-testing:2.2.0'
// Compose Testing
testImplementation 'androidx.compose.ui:ui-test-junit4'
debugImplementation 'androidx.compose.ui:ui-test-manifest'
// Espresso (instrumented tests)
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.5.1'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
// Screenshot Testing
androidTestImplementation 'com.github.pedrovgs:shot:6.1.0'
}
// Configure JUnit 5
tasks.withType(Test) {
useJUnitPlatform()
}
Test Configuration
// src/test/resources/robolectric.properties
sdk=34
ViewModel Testing
Testing ViewModel with StateFlow
// presentation/screens/payments/PaymentListViewModelTest.kt
package com.bank.paymentapp.presentation.screens.payments
import app.cash.turbine.test
import com.bank.paymentapp.domain.model.Payment
import com.bank.paymentapp.domain.model.PaymentStatus
import com.bank.paymentapp.domain.usecase.GetPaymentsUseCase
import com.google.common.truth.Truth.assertThat
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.*
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Nested
@OptIn(ExperimentalCoroutinesApi::class)
class PaymentListViewModelTest {
private lateinit var getPaymentsUseCase: GetPaymentsUseCase
private lateinit var viewModel: PaymentListViewModel
private val testDispatcher = StandardTestDispatcher()
@BeforeEach
fun setup() {
Dispatchers.setMain(testDispatcher)
getPaymentsUseCase = mockk()
}
@AfterEach
fun tearDown() {
Dispatchers.resetMain()
}
@Nested
@DisplayName("Initial State")
inner class InitialState {
@Test
@DisplayName("should start in loading state")
fun shouldStartInLoadingState() = runTest {
// Arrange
coEvery { getPaymentsUseCase() } coAnswers {
kotlinx.coroutines.delay(1000) // Simulate delay
Result.success(emptyList())
}
// Act
viewModel = PaymentListViewModel(getPaymentsUseCase)
// Assert
assertThat(viewModel.state.value).isInstanceOf(PaymentListState.Loading::class.java)
}
}
@Nested
@DisplayName("Load Payments")
inner class LoadPayments {
@Test
@DisplayName("should emit success state with payments")
fun shouldEmitSuccessStateWithPayments() = runTest {
// Arrange
val payments = listOf(
Payment(
id = "PAY-1",
recipientName = "John Doe",
amount = 100.0,
currency = "USD",
status = PaymentStatus.COMPLETED,
createdAt = System.currentTimeMillis(),
description = "Payment 1"
),
Payment(
id = "PAY-2",
recipientName = "Jane Smith",
amount = 200.0,
currency = "EUR",
status = PaymentStatus.PENDING,
createdAt = System.currentTimeMillis(),
description = "Payment 2"
)
)
coEvery { getPaymentsUseCase() } returns Result.success(payments)
// Act
viewModel = PaymentListViewModel(getPaymentsUseCase)
testDispatcher.scheduler.advanceUntilIdle()
// Assert
viewModel.state.test {
val state = awaitItem()
assertThat(state).isInstanceOf(PaymentListState.Success::class.java)
assertThat((state as PaymentListState.Success).payments).hasSize(2)
assertThat(state.payments[0].id).isEqualTo("PAY-1")
assertThat(state.payments[1].id).isEqualTo("PAY-2")
}
}
@Test
@DisplayName("should emit error state on failure")
fun shouldEmitErrorStateOnFailure() = runTest {
// Arrange
val errorMessage = "Network error"
coEvery { getPaymentsUseCase() } returns Result.failure(Exception(errorMessage))
// Act
viewModel = PaymentListViewModel(getPaymentsUseCase)
testDispatcher.scheduler.advanceUntilIdle()
// Assert
viewModel.state.test {
val state = awaitItem()
assertThat(state).isInstanceOf(PaymentListState.Error::class.java)
assertThat((state as PaymentListState.Error).message).isEqualTo(errorMessage)
}
}
@Test
@DisplayName("should call use case on initialization")
fun shouldCallUseCaseOnInitialization() = runTest {
// Arrange
coEvery { getPaymentsUseCase() } returns Result.success(emptyList())
// Act
viewModel = PaymentListViewModel(getPaymentsUseCase)
testDispatcher.scheduler.advanceUntilIdle()
// Assert
coVerify(exactly = 1) { getPaymentsUseCase() }
}
}
@Nested
@DisplayName("Refresh Payments")
inner class RefreshPayments {
@Test
@DisplayName("should reload payments on refresh")
fun shouldReloadPaymentsOnRefresh() = runTest {
// Arrange
val initialPayments = listOf(
Payment(
id = "PAY-1",
recipientName = "John Doe",
amount = 100.0,
currency = "USD",
status = PaymentStatus.COMPLETED,
createdAt = System.currentTimeMillis(),
description = null
)
)
val refreshedPayments = listOf(
Payment(
id = "PAY-1",
recipientName = "John Doe",
amount = 100.0,
currency = "USD",
status = PaymentStatus.COMPLETED,
createdAt = System.currentTimeMillis(),
description = null
),
Payment(
id = "PAY-2",
recipientName = "Jane Smith",
amount = 200.0,
currency = "EUR",
status = PaymentStatus.PENDING,
createdAt = System.currentTimeMillis(),
description = null
)
)
coEvery { getPaymentsUseCase() } returnsMany listOf(
Result.success(initialPayments),
Result.success(refreshedPayments)
)
viewModel = PaymentListViewModel(getPaymentsUseCase)
testDispatcher.scheduler.advanceUntilIdle()
// Act
viewModel.refreshPayments()
testDispatcher.scheduler.advanceUntilIdle()
// Assert
viewModel.state.test {
val state = awaitItem()
assertThat(state).isInstanceOf(PaymentListState.Success::class.java)
assertThat((state as PaymentListState.Success).payments).hasSize(2)
}
coVerify(exactly = 2) { getPaymentsUseCase() }
}
}
@Nested
@DisplayName("State Transitions")
inner class StateTransitions {
@Test
@DisplayName("should transition from loading to success")
fun shouldTransitionFromLoadingToSuccess() = runTest {
// Arrange
val payments = listOf(
Payment(
id = "PAY-1",
recipientName = "John Doe",
amount = 100.0,
currency = "USD",
status = PaymentStatus.COMPLETED,
createdAt = System.currentTimeMillis(),
description = null
)
)
coEvery { getPaymentsUseCase() } returns Result.success(payments)
// Act & Assert
viewModel = PaymentListViewModel(getPaymentsUseCase)
viewModel.state.test {
// First emission: Loading
assertThat(awaitItem()).isInstanceOf(PaymentListState.Loading::class.java)
// Advance coroutines
testDispatcher.scheduler.advanceUntilIdle()
// Second emission: Success
val successState = awaitItem()
assertThat(successState).isInstanceOf(PaymentListState.Success::class.java)
assertThat((successState as PaymentListState.Success).payments).hasSize(1)
}
}
}
}
Testing ViewModel with Complex Logic
// presentation/screens/payment/CreatePaymentViewModelTest.kt
package com.bank.paymentapp.presentation.screens.payment
import app.cash.turbine.test
import com.bank.paymentapp.domain.model.Payment
import com.bank.paymentapp.domain.usecase.CreatePaymentUseCase
import com.bank.paymentapp.domain.usecase.ValidatePaymentUseCase
import com.google.common.truth.Truth.assertThat
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.*
import org.junit.jupiter.api.*
@OptIn(ExperimentalCoroutinesApi::class)
class CreatePaymentViewModelTest {
private lateinit var createPaymentUseCase: CreatePaymentUseCase
private lateinit var validatePaymentUseCase: ValidatePaymentUseCase
private lateinit var viewModel: CreatePaymentViewModel
private val testDispatcher = StandardTestDispatcher()
@BeforeEach
fun setup() {
Dispatchers.setMain(testDispatcher)
createPaymentUseCase = mockk()
validatePaymentUseCase = mockk()
viewModel = CreatePaymentViewModel(createPaymentUseCase, validatePaymentUseCase)
}
@AfterEach
fun tearDown() {
Dispatchers.resetMain()
}
@Test
@DisplayName("should validate amount before creating payment")
fun shouldValidateAmountBeforeCreatingPayment() = runTest {
// Arrange
val invalidAmount = -100.0
coEvery { validatePaymentUseCase.validateAmount(invalidAmount) } returns false
// Act
viewModel.updateAmount(invalidAmount.toString())
viewModel.createPayment()
testDispatcher.scheduler.advanceUntilIdle()
// Assert
viewModel.validationErrors.test {
val errors = awaitItem()
assertThat(errors.amountError).isEqualTo("Amount must be positive")
}
coVerify(exactly = 0) { createPaymentUseCase(any()) }
}
@Test
@DisplayName("should create payment with valid data")
fun shouldCreatePaymentWithValidData() = runTest {
// Arrange
val amount = 100.0
val recipientName = "John Doe"
val currency = "USD"
coEvery { validatePaymentUseCase.validateAmount(amount) } returns true
coEvery { validatePaymentUseCase.validateRecipient(recipientName) } returns true
coEvery { createPaymentUseCase(any()) } returns Result.success(
Payment(
id = "PAY-123",
recipientName = recipientName,
amount = amount,
currency = currency,
status = com.bank.paymentapp.domain.model.PaymentStatus.PENDING,
createdAt = System.currentTimeMillis(),
description = null
)
)
// Act
viewModel.updateAmount(amount.toString())
viewModel.updateRecipient(recipientName)
viewModel.updateCurrency(currency)
viewModel.createPayment()
testDispatcher.scheduler.advanceUntilIdle()
// Assert
viewModel.uiState.test {
val state = awaitItem()
assertThat(state).isInstanceOf(CreatePaymentUiState.Success::class.java)
assertThat((state as CreatePaymentUiState.Success).paymentId).isEqualTo("PAY-123")
}
coVerify(exactly = 1) { createPaymentUseCase(any()) }
}
@Test
@DisplayName("should clear form after successful payment")
fun shouldClearFormAfterSuccessfulPayment() = runTest {
// Arrange
coEvery { validatePaymentUseCase.validateAmount(any()) } returns true
coEvery { validatePaymentUseCase.validateRecipient(any()) } returns true
coEvery { createPaymentUseCase(any()) } returns Result.success(mockk(relaxed = true))
viewModel.updateAmount("100")
viewModel.updateRecipient("John Doe")
// Act
viewModel.createPayment()
testDispatcher.scheduler.advanceUntilIdle()
// Assert
assertThat(viewModel.amount.value).isEmpty()
assertThat(viewModel.recipient.value).isEmpty()
}
}
Repository Testing
Testing Repository with Room Database
// data/repository/PaymentRepositoryImplTest.kt
package com.bank.paymentapp.data.repository
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import app.cash.turbine.test
import com.bank.paymentapp.data.local.PaymentDatabase
import com.bank.paymentapp.data.local.entities.PaymentEntity
import com.bank.paymentapp.data.remote.api.PaymentApi
import com.bank.paymentapp.data.remote.dto.PaymentDto
import com.bank.paymentapp.domain.model.PaymentStatus
import com.google.common.truth.Truth.assertThat
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class PaymentRepositoryImplTest {
private lateinit var database: PaymentDatabase
private lateinit var paymentApi: PaymentApi
private lateinit var repository: PaymentRepositoryImpl
@Before
fun setup() {
// Create in-memory database
database = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
PaymentDatabase::class.java
).allowMainThreadQueries().build()
paymentApi = mockk()
repository = PaymentRepositoryImpl(paymentApi, database.paymentDao())
}
@After
fun tearDown() {
database.close()
}
@Test
fun shouldReturnPaymentsFromDatabase() = runTest {
// Arrange
val payment1 = PaymentEntity(
id = "PAY-1",
recipientName = "John Doe",
amount = 100.0,
currency = "USD",
status = PaymentStatus.COMPLETED.name,
createdAt = System.currentTimeMillis(),
description = null
)
val payment2 = PaymentEntity(
id = "PAY-2",
recipientName = "Jane Smith",
amount = 200.0,
currency = "EUR",
status = PaymentStatus.PENDING.name,
createdAt = System.currentTimeMillis(),
description = null
)
database.paymentDao().insertAll(listOf(payment1, payment2))
// Act & Assert
repository.getPayments().test {
val payments = awaitItem()
assertThat(payments).hasSize(2)
assertThat(payments[0].id).isEqualTo("PAY-1")
assertThat(payments[1].id).isEqualTo("PAY-2")
}
}
@Test
fun shouldRefreshPaymentsFromApi() = runTest {
// Arrange
val apiPayments = listOf(
PaymentDto(
id = "PAY-API-1",
recipientName = "API User",
amount = 300.0,
currency = "GBP",
status = "COMPLETED",
createdAt = System.currentTimeMillis(),
description = "API Payment"
)
)
coEvery { paymentApi.getPayments() } returns apiPayments
// Act
val result = repository.refreshPayments()
// Assert
assertThat(result.isSuccess).isTrue()
coVerify(exactly = 1) { paymentApi.getPayments() }
// Verify database updated
repository.getPayments().test {
val payments = awaitItem()
assertThat(payments).hasSize(1)
assertThat(payments[0].id).isEqualTo("PAY-API-1")
}
}
@Test
fun shouldCreatePaymentAndCacheLocally() = runTest {
// Arrange
val payment = com.bank.paymentapp.domain.model.Payment(
id = "PAY-NEW",
recipientName = "New User",
amount = 150.0,
currency = "USD",
status = PaymentStatus.PENDING,
createdAt = System.currentTimeMillis(),
description = "New payment"
)
val createdDto = PaymentDto(
id = "PAY-NEW",
recipientName = "New User",
amount = 150.0,
currency = "USD",
status = "PENDING",
createdAt = System.currentTimeMillis(),
description = "New payment"
)
coEvery { paymentApi.createPayment(any()) } returns createdDto
// Act
val result = repository.createPayment(payment)
// Assert
assertThat(result.isSuccess).isTrue()
assertThat(result.getOrNull()?.id).isEqualTo("PAY-NEW")
// Verify cached locally
val cachedPayment = database.paymentDao().getById("PAY-NEW")
assertThat(cachedPayment).isNotNull()
assertThat(cachedPayment?.recipientName).isEqualTo("New User")
}
@Test
fun shouldHandleApiErrorGracefully() = runTest {
// Arrange
coEvery { paymentApi.getPayments() } throws Exception("Network error")
// Act
val result = repository.refreshPayments()
// Assert
assertThat(result.isFailure).isTrue()
assertThat(result.exceptionOrNull()?.message).isEqualTo("Network error")
}
}
Flow Testing with Turbine
Testing StateFlow Emissions
// domain/usecase/ObservePaymentsUseCaseTest.kt
package com.bank.paymentapp.domain.usecase
import app.cash.turbine.test
import com.bank.paymentapp.domain.model.Payment
import com.bank.paymentapp.domain.model.PaymentStatus
import com.bank.paymentapp.domain.repository.PaymentRepository
import com.google.common.truth.Truth.assertThat
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import kotlin.time.Duration.Companion.seconds
class ObservePaymentsUseCaseTest {
private lateinit var repository: PaymentRepository
private lateinit var useCase: ObservePaymentsUseCase
@BeforeEach
fun setup() {
repository = mockk()
useCase = ObservePaymentsUseCase(repository)
}
@Test
@DisplayName("should emit payments from repository")
fun shouldEmitPaymentsFromRepository() = runTest {
// Arrange
val payments = listOf(
Payment(
id = "PAY-1",
recipientName = "John Doe",
amount = 100.0,
currency = "USD",
status = PaymentStatus.COMPLETED,
createdAt = System.currentTimeMillis(),
description = null
)
)
every { repository.getPayments() } returns flowOf(payments)
// Act & Assert
useCase().test {
val emittedPayments = awaitItem()
assertThat(emittedPayments).hasSize(1)
assertThat(emittedPayments[0].id).isEqualTo("PAY-1")
awaitComplete()
}
}
@Test
@DisplayName("should filter pending payments")
fun shouldFilterPendingPayments() = runTest {
// Arrange
val payments = listOf(
Payment(
id = "PAY-1",
recipientName = "John Doe",
amount = 100.0,
currency = "USD",
status = PaymentStatus.PENDING,
createdAt = System.currentTimeMillis(),
description = null
),
Payment(
id = "PAY-2",
recipientName = "Jane Smith",
amount = 200.0,
currency = "EUR",
status = PaymentStatus.COMPLETED,
createdAt = System.currentTimeMillis(),
description = null
),
Payment(
id = "PAY-3",
recipientName = "Bob Johnson",
amount = 300.0,
currency = "GBP",
status = PaymentStatus.PENDING,
createdAt = System.currentTimeMillis(),
description = null
)
)
every { repository.getPayments() } returns flowOf(payments)
// Act & Assert
useCase.observePendingPayments().test {
val pendingPayments = awaitItem()
assertThat(pendingPayments).hasSize(2)
assertThat(pendingPayments.all { it.status == PaymentStatus.PENDING }).isTrue()
awaitComplete()
}
}
@Test
@DisplayName("should emit multiple updates over time")
fun shouldEmitMultipleUpdatesOverTime() = runTest {
// Arrange
val initialPayments = listOf(
Payment(
id = "PAY-1",
recipientName = "John Doe",
amount = 100.0,
currency = "USD",
status = PaymentStatus.PENDING,
createdAt = System.currentTimeMillis(),
description = null
)
)
val updatedPayments = listOf(
Payment(
id = "PAY-1",
recipientName = "John Doe",
amount = 100.0,
currency = "USD",
status = PaymentStatus.COMPLETED,
createdAt = System.currentTimeMillis(),
description = null
),
Payment(
id = "PAY-2",
recipientName = "Jane Smith",
amount = 200.0,
currency = "EUR",
status = PaymentStatus.PENDING,
createdAt = System.currentTimeMillis(),
description = null
)
)
every { repository.getPayments() } returns flowOf(initialPayments, updatedPayments)
// Act & Assert
useCase().test(timeout = 5.seconds) {
// First emission
val first = awaitItem()
assertThat(first).hasSize(1)
assertThat(first[0].status).isEqualTo(PaymentStatus.PENDING)
// Second emission
val second = awaitItem()
assertThat(second).hasSize(2)
assertThat(second[0].status).isEqualTo(PaymentStatus.COMPLETED)
awaitComplete()
}
}
}
Jetpack Compose UI Testing
Testing Composable Components
// presentation/components/PaymentCardTest.kt
package com.bank.paymentapp.presentation.components
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import com.bank.paymentapp.domain.model.Payment
import com.bank.paymentapp.domain.model.PaymentStatus
import com.bank.paymentapp.presentation.theme.PaymentAppTheme
import com.google.common.truth.Truth.assertThat
import org.junit.Rule
import org.junit.Test
class PaymentCardTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun shouldDisplayPaymentInformation() {
// Arrange
val payment = Payment(
id = "PAY-123",
recipientName = "John Doe",
amount = 100.0,
currency = "USD",
status = PaymentStatus.COMPLETED,
createdAt = System.currentTimeMillis(),
description = "Test payment"
)
// Act
composeTestRule.setContent {
PaymentAppTheme {
PaymentCard(payment = payment, onClick = {})
}
}
// Assert
composeTestRule.onNodeWithText("John Doe").assertIsDisplayed()
composeTestRule.onNodeWithText("$100.00 USD").assertIsDisplayed()
composeTestRule.onNodeWithText("Completed").assertIsDisplayed()
}
@Test
fun shouldCallOnClickWhenCardClicked() {
// Arrange
var clicked = false
val payment = Payment(
id = "PAY-123",
recipientName = "John Doe",
amount = 100.0,
currency = "USD",
status = PaymentStatus.PENDING,
createdAt = System.currentTimeMillis(),
description = null
)
composeTestRule.setContent {
PaymentAppTheme {
PaymentCard(payment = payment, onClick = { clicked = true })
}
}
// Act
composeTestRule.onNodeWithText("John Doe").performClick()
// Assert
assertThat(clicked).isTrue()
}
@Test
fun shouldShowCorrectStatusChipColor() {
// Arrange
val completedPayment = Payment(
id = "PAY-1",
recipientName = "John Doe",
amount = 100.0,
currency = "USD",
status = PaymentStatus.COMPLETED,
createdAt = System.currentTimeMillis(),
description = null
)
// Act
composeTestRule.setContent {
PaymentAppTheme {
PaymentCard(payment = completedPayment, onClick = {})
}
}
// Assert
composeTestRule.onNodeWithText("Completed")
.assertIsDisplayed()
.assertHasClickAction()
}
@Test
fun shouldFormatCurrencyCorrectly() {
// Arrange
val payment = Payment(
id = "PAY-1",
recipientName = "John Doe",
amount = 1234.56,
currency = "EUR",
status = PaymentStatus.PENDING,
createdAt = System.currentTimeMillis(),
description = null
)
// Act
composeTestRule.setContent {
PaymentAppTheme {
PaymentCard(payment = payment, onClick = {})
}
}
// Assert
composeTestRule.onNodeWithText("€1,234.56 EUR").assertIsDisplayed()
}
}
Testing Screens with ViewModels
// presentation/screens/payments/PaymentListScreenTest.kt
package com.bank.paymentapp.presentation.screens.payments
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import com.bank.paymentapp.domain.model.Payment
import com.bank.paymentapp.domain.model.PaymentStatus
import com.bank.paymentapp.presentation.theme.PaymentAppTheme
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class PaymentListScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
private lateinit var viewModel: PaymentListViewModel
private val stateFlow = MutableStateFlow<PaymentListState>(PaymentListState.Loading)
@Before
fun setup() {
viewModel = mockk(relaxed = true)
every { viewModel.state } returns stateFlow
}
@Test
fun shouldShowLoadingIndicator() {
// Arrange
stateFlow.value = PaymentListState.Loading
// Act
composeTestRule.setContent {
PaymentAppTheme {
PaymentListScreen(viewModel = viewModel, onPaymentClick = {})
}
}
// Assert
composeTestRule.onNodeWithTag("loading").assertIsDisplayed()
}
@Test
fun shouldShowPaymentList() {
// Arrange
val payments = listOf(
Payment(
id = "PAY-1",
recipientName = "John Doe",
amount = 100.0,
currency = "USD",
status = PaymentStatus.COMPLETED,
createdAt = System.currentTimeMillis(),
description = null
),
Payment(
id = "PAY-2",
recipientName = "Jane Smith",
amount = 200.0,
currency = "EUR",
status = PaymentStatus.PENDING,
createdAt = System.currentTimeMillis(),
description = null
)
)
stateFlow.value = PaymentListState.Success(payments)
// Act
composeTestRule.setContent {
PaymentAppTheme {
PaymentListScreen(viewModel = viewModel, onPaymentClick = {})
}
}
// Assert
composeTestRule.onNodeWithText("John Doe").assertIsDisplayed()
composeTestRule.onNodeWithText("Jane Smith").assertIsDisplayed()
}
@Test
fun shouldShowErrorMessage() {
// Arrange
stateFlow.value = PaymentListState.Error("Network error")
// Act
composeTestRule.setContent {
PaymentAppTheme {
PaymentListScreen(viewModel = viewModel, onPaymentClick = {})
}
}
// Assert
composeTestRule.onNodeWithText("Network error").assertIsDisplayed()
composeTestRule.onNodeWithText("Retry").assertIsDisplayed()
}
@Test
fun shouldCallOnPaymentClickWhenCardClicked() {
// Arrange
var clickedPaymentId: String? = null
val payments = listOf(
Payment(
id = "PAY-123",
recipientName = "John Doe",
amount = 100.0,
currency = "USD",
status = PaymentStatus.COMPLETED,
createdAt = System.currentTimeMillis(),
description = null
)
)
stateFlow.value = PaymentListState.Success(payments)
composeTestRule.setContent {
PaymentAppTheme {
PaymentListScreen(
viewModel = viewModel,
onPaymentClick = { clickedPaymentId = it }
)
}
}
// Act
composeTestRule.onNodeWithText("John Doe").performClick()
// Assert
assertThat(clickedPaymentId).isEqualTo("PAY-123")
}
@Test
fun shouldCallRefreshOnRetryClick() {
// Arrange
stateFlow.value = PaymentListState.Error("Network error")
composeTestRule.setContent {
PaymentAppTheme {
PaymentListScreen(viewModel = viewModel, onPaymentClick = {})
}
}
// Act
composeTestRule.onNodeWithText("Retry").performClick()
// Assert
verify(exactly = 1) { viewModel.loadPayments() }
}
}
Espresso UI Testing
Testing Activities with Espresso
// presentation/MainActivity Espresso Test
package com.bank.paymentapp.presentation
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.*
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.bank.paymentapp.R
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class MainActivityTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val activityRule = ActivityScenarioRule(MainActivity::class.java)
@Before
fun setup() {
hiltRule.inject()
}
@Test
fun shouldDisplayPaymentList() {
onView(withId(R.id.payment_list))
.check(matches(isDisplayed()))
}
@Test
fun shouldNavigateToPaymentDetailsWhenCardClicked() {
// Click first payment card
onView(withText("John Doe"))
.perform(click())
// Verify navigation to detail screen
onView(withId(R.id.payment_detail_screen))
.check(matches(isDisplayed()))
}
@Test
fun shouldCreatePaymentWithValidData() {
// Open create payment screen
onView(withId(R.id.fab_create_payment))
.perform(click())
// Fill form
onView(withId(R.id.input_recipient))
.perform(typeText("Jane Smith"), closeSoftKeyboard())
onView(withId(R.id.input_amount))
.perform(typeText("150.00"), closeSoftKeyboard())
onView(withId(R.id.input_currency))
.perform(typeText("USD"), closeSoftKeyboard())
// Submit
onView(withId(R.id.button_submit))
.perform(click())
// Verify success message
onView(withText("Payment created successfully"))
.check(matches(isDisplayed()))
}
@Test
fun shouldShowValidationErrorForInvalidAmount() {
onView(withId(R.id.fab_create_payment))
.perform(click())
onView(withId(R.id.input_amount))
.perform(typeText("-100"), closeSoftKeyboard())
onView(withId(R.id.button_submit))
.perform(click())
onView(withText("Amount must be positive"))
.check(matches(isDisplayed()))
}
}
Screenshot Testing
Setup Screenshot Testing
// build.gradle (Module :app)
apply plugin: 'shot'
shot {
appId = 'com.bank.paymentapp'
}
Creating Screenshot Tests
// presentation/components/PaymentCardScreenshotTest.kt
package com.bank.paymentapp.presentation.components
import androidx.compose.ui.test.junit4.createComposeRule
import com.bank.paymentapp.domain.model.Payment
import com.bank.paymentapp.domain.model.PaymentStatus
import com.bank.paymentapp.presentation.theme.PaymentAppTheme
import com.karumi.shot.ScreenshotTest
import org.junit.Rule
import org.junit.Test
class PaymentCardScreenshotTest : ScreenshotTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun shouldRenderCompletedPayment() {
val payment = Payment(
id = "PAY-1",
recipientName = "John Doe",
amount = 100.0,
currency = "USD",
status = PaymentStatus.COMPLETED,
createdAt = System.currentTimeMillis(),
description = "Test payment"
)
composeTestRule.setContent {
PaymentAppTheme {
PaymentCard(payment = payment, onClick = {})
}
}
compareScreenshot(composeTestRule, "payment_card_completed")
}
@Test
fun shouldRenderPendingPayment() {
val payment = Payment(
id = "PAY-2",
recipientName = "Jane Smith",
amount = 250.50,
currency = "EUR",
status = PaymentStatus.PENDING,
createdAt = System.currentTimeMillis(),
description = "Pending payment"
)
composeTestRule.setContent {
PaymentAppTheme {
PaymentCard(payment = payment, onClick = {})
}
}
compareScreenshot(composeTestRule, "payment_card_pending")
}
@Test
fun shouldRenderFailedPayment() {
val payment = Payment(
id = "PAY-3",
recipientName = "Bob Johnson",
amount = 500.0,
currency = "GBP",
status = PaymentStatus.FAILED,
createdAt = System.currentTimeMillis(),
description = "Failed payment"
)
composeTestRule.setContent {
PaymentAppTheme {
PaymentCard(payment = payment, onClick = {})
}
}
compareScreenshot(composeTestRule, "payment_card_failed")
}
}
Running Screenshot Tests
# Record baseline screenshots
./gradlew executeScreenshotTests -Precord
# Verify screenshots (compare against baseline)
./gradlew executeScreenshotTests
# View screenshot test report
open app/build/reports/shot/verification/index.html
Test Structure and Organization
Test Directory Structure
app/
├── src/
│ ├── test/ # Unit tests
│ │ ├── java/com/bank/paymentapp/
│ │ │ ├── presentation/
│ │ │ │ └── screens/
│ │ │ │ └── PaymentListViewModelTest.kt
│ │ │ ├── domain/
│ │ │ │ └── usecase/
│ │ │ │ └── GetPaymentsUseCaseTest.kt
│ │ │ └── data/
│ │ │ └── repository/
│ │ │ └── PaymentRepositoryImplTest.kt
│ │ └── resources/
│ │ └── robolectric.properties
│ │
│ └── androidTest/ # Instrumented tests
│ └── java/com/bank/paymentapp/
│ ├── presentation/
│ │ ├── MainActivityTest.kt
│ │ └── components/
│ │ ├── PaymentCardTest.kt
│ │ └── PaymentCardScreenshotTest.kt
│ └── data/
│ └── local/
│ └── PaymentDaoTest.kt
Base Test Classes
// test/java/com/bank/paymentapp/base/BaseViewModelTest.kt
package com.bank.paymentapp.base
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.*
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
@OptIn(ExperimentalCoroutinesApi::class)
abstract class BaseViewModelTest {
protected val testDispatcher = StandardTestDispatcher()
@BeforeEach
fun setupBase() {
Dispatchers.setMain(testDispatcher)
}
@AfterEach
fun tearDownBase() {
Dispatchers.resetMain()
}
protected fun advanceTime() {
testDispatcher.scheduler.advanceUntilIdle()
}
}
Common Pitfalls
Don't: Test Implementation Details
// BAD: Testing internal state directly
@Test
fun shouldUpdateInternalState() {
val viewModel = PaymentListViewModel(mockk())
viewModel.internalPaymentList.add(mockk()) // DON'T ACCESS INTERNAL STATE
assertThat(viewModel.internalPaymentList).hasSize(1)
}
// GOOD: Test observable state
@Test
fun shouldUpdatePublicState() = runTest {
coEvery { getPaymentsUseCase() } returns Result.success(listOf(mockPayment))
val viewModel = PaymentListViewModel(getPaymentsUseCase)
testDispatcher.scheduler.advanceUntilIdle()
viewModel.state.test {
val state = awaitItem()
assertThat(state).isInstanceOf(PaymentListState.Success::class.java)
}
}
Don't: Forget to Advance Test Dispatcher
// BAD: Not advancing dispatcher
@Test
fun shouldLoadPayments() = runTest {
coEvery { getPaymentsUseCase() } returns Result.success(payments)
val viewModel = PaymentListViewModel(getPaymentsUseCase)
// State is still Loading because coroutines haven't executed!
assertThat(viewModel.state.value).isInstanceOf(PaymentListState.Success::class.java) // FAILS
}
// GOOD: Advance dispatcher
@Test
fun shouldLoadPayments() = runTest {
coEvery { getPaymentsUseCase() } returns Result.success(payments)
val viewModel = PaymentListViewModel(getPaymentsUseCase)
testDispatcher.scheduler.advanceUntilIdle() // Execute all pending coroutines
assertThat(viewModel.state.value).isInstanceOf(PaymentListState.Success::class.java)
}
Don't: Use Real Database in Unit Tests
// BAD: Using real Room database in unit test
@Test
fun shouldSavePayment() {
val database = Room.databaseBuilder(context, PaymentDatabase::class.java, "test.db").build()
// Slow and couples test to database implementation
}
// GOOD: Mock repository in unit test
@Test
fun shouldSavePayment() = runTest {
val repository = mockk<PaymentRepository>()
coEvery { repository.createPayment(any()) } returns Result.success(mockPayment)
// Test business logic, not database
}
// ALSO GOOD: Use in-memory database in integration test
@RunWith(AndroidJUnit4::class)
class PaymentRepositoryIntegrationTest {
private lateinit var database: PaymentDatabase
@Before
fun setup() {
database = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
PaymentDatabase::class.java
).allowMainThreadQueries().build()
}
}
Further Reading
Android Framework Guidelines
- Android Overview - Project setup, Gradle, Hilt DI
- Android UI - Testing Jetpack Compose UI
- Android Data - Testing repositories and network layer
- Android Security - Security testing approaches
- Android Architecture - Testing layered architecture
- Android Performance - Performance testing
Testing Strategy
- Testing Strategy - Overall testing approach and honeycomb model
- Unit Testing - Unit testing principles across platforms
- Integration Testing - Integration test patterns
- Mutation Testing - Mutation testing strategies
- CI Testing - CI pipeline testing
Language Testing
- Kotlin Testing - Kotlin-specific testing patterns
External Resources
External Resources
- Android Testing Documentation
- Jetpack Compose Testing
- MockK Documentation
- Turbine GitHub
- Espresso Testing
- Shot Screenshot Testing
Summary
Key Takeaways
- Testing Honeycomb - Integration tests (50-60%), unit tests (30-40%), UI tests (<10%)
- MockK for Kotlin - Use MockK instead of Mockito for better Kotlin support
- Turbine for Flows - Test StateFlow and Flow emissions with time-based assertions
- Test Dispatcher Management - Always advance test dispatcher in coroutine tests
- Compose Semantics Testing - Test Compose UI by semantics (text, role, tag), not internals
- In-Memory Room Database - Use in-memory database for integration tests
- Screenshot Testing - Catch visual regressions automatically with Shot
- ViewModel Testing - Test state emissions, not internal implementation
- Robolectric - Run Android framework tests without emulator
- Test Organization - Separate unit tests, integration tests, and instrumented tests clearly
Next Steps: Review Android Architecture for advanced architectural patterns and Testing Strategy for team-wide testing guidelines.