Daily Learnings: Architecture Patterns and Testing Strategies

9 min read
#Android #Testing #Jetpack Compose

The Testing Mindset: Building for Confidence

Testing isn’t just about catching bugs—it’s about building systems you can confidently modify, refactor, and scale. In modern Android development, where we juggle ViewModels, Compose UI, coroutines, and dependency injection, a solid testing strategy becomes the foundation of sustainable development.

This guide explores architectural patterns and testing strategies that emerged from real-world challenges in Android testing, covering everything from ViewModel test design to animation synchronization in Compose.


Core Architecture Principles

The Testing Pyramid for Android

Modern Android testing follows a layered approach:

  1. Pure Unit Tests (70%): Business logic with zero Android dependencies
  2. Robolectric Tests (20%): Android framework interactions on JVM
  3. Instrumented Tests (10%): Real device/emulator for integration

The goal is to push as much logic as possible toward the top of the pyramid where tests run fastest and are most reliable.

Separation of Concerns in ViewModels

ViewModels serve as business logic holders that expose state to the UI and persist through configuration changes. The key is keeping them testable by avoiding tight coupling with the Android framework.

Anti-Pattern: Loading in init {}

Loading data in the init block couples data fetching tightly with the ViewModel’s lifecycle, making tests harder to control and potentially causing premature data loads.

// Avoid: Tight coupling
class ProfileViewModel : ViewModel() {
    init {
        loadUserProfile() // Runs immediately on instantiation
    }
}

Better Pattern: Event-Driven Loading

Modern approaches leverage Kotlin Flows, transforming cold flows into hot StateFlows with proper sharing strategies for predictable behavior.

// Better: Explicit control
class ProfileViewModel(
    private val getUserUseCase: GetUserUseCase
) : ViewModel() {
    
    private val _uiState = MutableStateFlow<ProfileUiState>(ProfileUiState.Idle)
    val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
    
    fun loadProfile(userId: String) {
        viewModelScope.launch {
            _uiState.value = ProfileUiState.Loading
            getUserUseCase(userId)
                .onSuccess { user -> 
                    _uiState.value = ProfileUiState.Success(user) 
                }
                .onFailure { error -> 
                    _uiState.value = ProfileUiState.Error(error.message) 
                }
        }
    }
}

ViewModel Testing Strategies

Test Setup Architecture

MockK is the preferred mocking library for Kotlin Android development, offering cleaner syntax and better Kotlin integration than Mockito.

@ExperimentalCoroutinesApi
class ProfileViewModelTest {
    
    // Test dispatcher for coroutines
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()
    
    // Mock dependencies
    private lateinit var getUserUseCase: GetUserUseCase
    private lateinit var viewModel: ProfileViewModel
    
    @Before
    fun setup() {
        getUserUseCase = mockk()
        viewModel = ProfileViewModel(getUserUseCase)
    }
    
    @After
    fun tearDown() {
        clearAllMocks()
    }
}

Testing State Transitions

Tests should focus on behavior—what the user experiences—rather than implementation details.

@Test
fun `loadProfile emits loading then success states`() = runTest {
    // Given
    val userId = "user_123"
    val expectedUser = User(id = userId, name = "Jane Doe")
    coEvery { getUserUseCase(userId) } returns Result.success(expectedUser)
    
    val states = mutableListOf<ProfileUiState>()
    val job = launch(UnconfinedTestDispatcher(testScheduler)) {
        viewModel.uiState.toList(states)
    }
    
    // When
    viewModel.loadProfile(userId)
    advanceUntilIdle()
    
    // Then
    assertThat(states).containsExactly(
        ProfileUiState.Idle,
        ProfileUiState.Loading,
        ProfileUiState.Success(expectedUser)
    )
    
    job.cancel()
}

Testing Error Scenarios

@Test
fun `loadProfile emits error state on failure`() = runTest {
    // Given
    val userId = "user_123"
    val errorMessage = "Network error"
    coEvery { getUserUseCase(userId) } returns Result.failure(
        IOException(errorMessage)
    )
    
    // When
    viewModel.loadProfile(userId)
    advanceUntilIdle()
    
    // Then
    val currentState = viewModel.uiState.value
    assertThat(currentState).isInstanceOf(ProfileUiState.Error::class.java)
    assertThat((currentState as ProfileUiState.Error).message).isEqualTo(errorMessage)
}

Compose Animation Testing

The Synchronization Challenge

Compose tests are synchronized by default with your UI using a virtual clock, meaning tests don’t run in real time and can pass as fast as possible. However, animations introduce complexity.

Infinite animations cause the test to never finish because the app is never idle.

Controlling the Test Clock

ComposeTestRule provides control over the test clock with autoAdvance settings and manual time advancement functions.

@get:Rule
val composeTestRule = createComposeRule()

@Test
fun `animated content displays after animation completes`() {
    // Disable auto-advance before setting content
    composeTestRule.mainClock.autoAdvance = false
    
    composeTestRule.setContent {
        AnimatedGreeting()
    }
    
    // Initially, greeting should not be visible
    composeTestRule
        .onNodeWithText("Hello, World!")
        .assertDoesNotExist()
    
    // Advance time by animation duration (300ms)
    composeTestRule.mainClock.advanceTimeBy(300)
    
    // Now the greeting should be visible
    composeTestRule
        .onNodeWithText("Hello, World!")
        .assertIsDisplayed()
}

Gated Animation Pattern

To make components testable, gate animations behind a flag:

@Composable
fun AnimatedGreeting(enableAnimation: Boolean = true) {
    var isVisible by remember { mutableStateOf(!enableAnimation) }
    
    LaunchedEffect(enableAnimation) {
        if (enableAnimation) {
            delay(300)
            isVisible = true
        }
    }
    
    if (enableAnimation) {
        AnimatedVisibility(
            visible = isVisible,
            enter = fadeIn(animationSpec = tween(300))
        ) {
            Text("Hello, World!")
        }
    } else {
        if (isVisible) {
            Text("Hello, World!")
        }
    }
}

Waiting for Conditions

The waitUntil API provides a way to synchronize tests by waiting for specific conditions to be met, offering a better alternative to arbitrary delays.

@Test
fun `data loads and displays correctly`() {
    composeTestRule.setContent {
        DataScreen()
    }
    
    // Wait for loading to complete and data to appear
    composeTestRule.waitUntil(timeoutMillis = 5000) {
        composeTestRule
            .onAllNodesWithText("Loading...")
            .fetchSemanticsNodes()
            .isEmpty()
    }
    
    // Verify content is displayed
    composeTestRule
        .onNodeWithText("Data loaded successfully")
        .assertIsDisplayed()
}

Robolectric vs AndroidJUnit4: Making the Choice

When to Use Robolectric

Robolectric lets you run tests in a simulated Android environment inside a JVM without the overhead of an emulator, making tests run up to 10x faster.

Use Robolectric for:

Robolectric is recommended for isolated UI behavior tests for components and state management, but not for all scenarios.

@RunWith(AndroidJUnit4::class)
class UserRepositoryTest {
    
    private lateinit var context: Context
    private lateinit var repository: UserRepository
    
    @Before
    fun setup() {
        context = ApplicationProvider.getApplicationContext()
        repository = UserRepository(context)
    }
    
    @Test
    fun `saveUser persists data to SharedPreferences`() {
        val user = User(id = "123", name = "John")
        
        repository.saveUser(user)
        
        val saved = repository.getUser("123")
        assertThat(saved).isEqualTo(user)
    }
}

When to Use Instrumented Tests

Some cases require device tests, such as those related to system UI features like edge-to-edge or picture-in-picture, or when relying on unsupported features like WebView.

Use Instrumented Tests for:

Hybrid Approach

The best strategy is to use a tiered approach: pure unit tests for business logic, Robolectric where minimal Android dependencies exist, and instrumented tests where the real Android framework is necessary.


Dependency Injection in Tests

Constructor Injection Pattern

The cleanest approach for testability:

class PaymentViewModel(
    private val processPaymentUseCase: ProcessPaymentUseCase,
    private val analyticsTracker: AnalyticsTracker
) : ViewModel() {
    
    fun processPayment(amount: Double) {
        viewModelScope.launch {
            analyticsTracker.track("payment_initiated", mapOf("amount" to amount))
            processPaymentUseCase(amount)
                .onSuccess { analyticsTracker.track("payment_success") }
                .onFailure { analyticsTracker.track("payment_failure") }
        }
    }
}

// Test becomes straightforward
class PaymentViewModelTest {
    @Test
    fun `processPayment tracks analytics events`() = runTest {
        val mockUseCase = mockk<ProcessPaymentUseCase>()
        val mockTracker = mockk<AnalyticsTracker>(relaxed = true)
        val viewModel = PaymentViewModel(mockUseCase, mockTracker)
        
        coEvery { mockUseCase(any()) } returns Result.success(Unit)
        
        viewModel.processPayment(99.99)
        advanceUntilIdle()
        
        verify { mockTracker.track("payment_initiated", any()) }
        verify { mockTracker.track("payment_success") }
    }
}

Testing with Hilt

For projects using Hilt, leverage test-specific modules:

@HiltAndroidTest
class CheckoutViewModelTest {
    
    @get:Rule
    var hiltRule = HiltAndroidRule(this)
    
    @Inject
    lateinit var viewModel: CheckoutViewModel
    
    @Before
    fun init() {
        hiltRule.inject()
    }
    
    @Test
    fun `checkout flow completes successfully`() = runTest {
        // Test implementation
    }
}

@Module
@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [ProductionModule::class]
)
object TestModule {
    @Provides
    fun providePaymentService(): PaymentService = FakePaymentService()
}

SavedStateHandle Testing Patterns

The Navigation Args Challenge

When ViewModels read navigation arguments from SavedStateHandle:

class ProductDetailViewModel(
    savedStateHandle: SavedStateHandle
) : ViewModel() {
    
    private val productId: String = 
        savedStateHandle.get<String>("productId") 
            ?: throw IllegalArgumentException("Product ID required")
    
    // ViewModel logic...
}

Testing Approach

Provide real SavedStateHandle instances per test:

class ProductDetailViewModelTest {
    
    private fun createViewModel(productId: String): ProductDetailViewModel {
        val savedState = SavedStateHandle(mapOf("productId" to productId))
        return ProductDetailViewModel(savedState)
    }
    
    @Test
    fun `loads product with provided ID`() = runTest {
        val viewModel = createViewModel("product_123")
        
        // Test assertions
        assertThat(viewModel.productId).isEqualTo("product_123")
    }
    
    @Test
    fun `throws exception when product ID missing`() {
        assertThrows<IllegalArgumentException> {
            ProductDetailViewModel(SavedStateHandle())
        }
    }
}

Key Takeaways

Testing Philosophy

  1. Test behavior, not implementation: Focus on what users experience
  2. Keep tests fast: Push logic to pure unit tests when possible
  3. Make tests readable: Future you (or your teammates) will thank you
  4. Avoid test-specific code in production: Use dependency injection instead

Architecture Decisions

Common Pitfalls to Avoid


📚 Further Reading & References

Official Documentation

Testing Frameworks & Tools

Advanced Topics

Best Practices Articles


Testing is an investment in confidence. By following these architectural patterns and testing strategies, you’re building Android applications that can evolve safely over time. Whether you’re testing ViewModels, animations, or complex user flows, the key is choosing the right tool for the job and designing your code with testability in mind from the start.

What testing challenges have you encountered in your Android projects? Share your experiences and let’s learn together!

About the Author

Aniket Indulkar is an Android Engineer based in London with a Master's in Artificial Intelligence. He writes about AI, ML, Android development, and his continuous learning journey.

Connect on LinkedIn →