Daily Learnings: Architecture Patterns and Testing Strategies
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:
- Pure Unit Tests (70%): Business logic with zero Android dependencies
- Robolectric Tests (20%): Android framework interactions on JVM
- 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:
- Unit tests that need minimal Android framework dependencies
- ViewModel tests with simple Context requirements
- Testing business logic that touches Android APIs
- Rapid iteration during development
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:
- Complex animation testing requiring pixel-perfect accuracy
- Integration tests with system services
- Testing camera, sensors, or GPS functionality
- End-to-end user flows
- Performance benchmarking
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
- Test behavior, not implementation: Focus on what users experience
- Keep tests fast: Push logic to pure unit tests when possible
- Make tests readable: Future you (or your teammates) will thank you
- Avoid test-specific code in production: Use dependency injection instead
Architecture Decisions
- Prefer event-driven ViewModel loading over init-block execution
- Use constructor injection for dependencies whenever possible
- Gate animations behind flags for controllable testing
- Choose the right testing framework based on what you’re testing
Common Pitfalls to Avoid
- ❌ Using arbitrary delays (
delay(500)) instead of proper synchronization - ❌ Creating ViewModels before mocking their dependencies
- ❌ Testing too many concerns in a single test
- ❌ Exposing mutable state directly from ViewModels
- ❌ Forgetting to advance the test dispatcher in coroutine tests
📚 Further Reading & References
Official Documentation
- Android ViewModel Overview - Official guide to ViewModel architecture and lifecycle management
- Write Unit Tests for ViewModel - Google’s official codelab on ViewModel testing
- Compose Testing Synchronization - Official documentation on Compose test synchronization and clock control
- Test Animations in Compose - Guide to deterministic animation testing in Compose
- Robolectric Strategies - Official Android guide to using Robolectric effectively
Testing Frameworks & Tools
- MockK Documentation - The Kotlin-first mocking library for Android
- Robolectric - Industry-standard JVM-based Android testing framework
- Turbine - Library for testing Kotlin Flows
Advanced Topics
- Alternatives to Idling Resources - Jose Alcérreca’s guide to modern Compose test synchronization
- Android ViewModel Data Loading - Flow-based architecture patterns for ViewModels
- Testing in Jetpack Compose Codelab - Comprehensive codelab covering Compose UI testing fundamentals
Best Practices Articles
- Mastering Android ViewModels: Essential Dos and Don’ts - Deep dive into ViewModel anti-patterns and solutions
- Complete Guide to Unit Testing ViewModels with MockK - Practical examples of ViewModel testing patterns
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!