Skip to main content

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

  1. Testing Honeycomb: Integration tests (50-60%), unit tests (30-40%), UI tests (<10%)
  2. Test Real Android Components: Use Robolectric for faster unit tests with Android dependencies
  3. MockK Over Mockito: Kotlin-first mocking with coroutines support
  4. Turbine for Flows: Test StateFlow and Flow emissions with time-based assertions
  5. Compose Test Semantics: Test Compose UI by semantics, not implementation details
  6. Screenshot Testing: Catch visual regressions with automated screenshot comparison
  7. 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

Testing Strategy

Language Testing

External Resources

External Resources


Summary

Key Takeaways

  1. Testing Honeycomb - Integration tests (50-60%), unit tests (30-40%), UI tests (<10%)
  2. MockK for Kotlin - Use MockK instead of Mockito for better Kotlin support
  3. Turbine for Flows - Test StateFlow and Flow emissions with time-based assertions
  4. Test Dispatcher Management - Always advance test dispatcher in coroutine tests
  5. Compose Semantics Testing - Test Compose UI by semantics (text, role, tag), not internals
  6. In-Memory Room Database - Use in-memory database for integration tests
  7. Screenshot Testing - Catch visual regressions automatically with Shot
  8. ViewModel Testing - Test state emissions, not internal implementation
  9. Robolectric - Run Android framework tests without emulator
  10. 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.