The Testing Gap I Discovered
When I transitioned our Android development at CodeBrew Labs to Jetpack Compose, we hit a wall. Our traditional Android testing strategies — unit tests for ViewModels, Espresso for UI — suddenly felt incomplete. Compose's declarative nature changed everything about how we should test.
We shipped 6 production apps with 4.5+ star ratings, but our first Compose app nearly slipped through with subtle state bugs because our testing approach hadn't evolved. That's when I realized: Android development with Jetpack Compose demands a rethinking of your entire testing strategy.
Over the past 8 years in Android development, I've learned that testing isn't a checkbox — it's insurance. And when you're building modern Android apps with Compose, your testing pyramid needs to be different.
Android Testing Layers for Compose
Traditional Android architecture typically uses a three-layer testing pyramid: unit tests at the base, integration tests in the middle, and end-to-end tests at the top. With Jetpack Compose, this structure still applies, but the boundaries shift.
Here's how I've structured testing across my Compose projects:
- Unit Tests (70%): ViewModel logic, state calculations, business logic — exactly like before, but your Compose functions become testable when you separate them from state.
- Compose UI Tests (20%): Test composables in isolation using
ComposeTestRule— this is where Compose testing differs most from traditional Android. - Integration Tests (10%): Test full screens with real dependencies, navigation flows, and data interactions.
The key insight: Compose lets you test UI logic without fighting the Android lifecycle.
UI Testing in Jetpack Compose
When I started testing Jetpack Compose, I quickly abandoned Espresso. Google's ComposeTestRule is built specifically for declarative UIs, and it's miles better.
The setup is straightforward:
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun testButtonClickedShowsMessage() {
composeTestRule.setContent {
var clicked by remember { mutableStateOf(false) }
Column {
Button(onClick = { clicked = true }) {
Text("Click Me")
}
if (clicked) {
Text("Button was clicked!")
}
}
}
composeTestRule.onNodeWithText("Click Me").performClick()
composeTestRule.onNodeWithText("Button was clicked!").assertExists()
}What makes this powerful: you're testing the exact composable in isolation, without needing to navigate through activities or deal with fragments. In my AudioBook AI app (50K+ users), this approach cut our UI test execution time by 60% compared to Espresso.
Semantic Testing with Compose
Compose encourages semantic testing — you test what users see and interact with, not implementation details. Use matchers like onNodeWithText(), onNodeWithTag(), and onNodeWithContentDescription().
Tag your composables strategically:
TextField(
value = email,
onValueChange = { email = it },
modifier = Modifier.testTag("email_input"),
label = { Text("Email") }
)
// In your test:
composeTestRule.onNodeWithTag("email_input")
.performTextInput("test@example.com")
.assertTextEquals("test@example.com")This approach is resilient — when you refactor the underlying implementation, tests don't break unless the user-visible behavior changes.
State & ViewModel Testing
In MVVM Android architecture with Compose, your ViewModel is where business logic lives. Testing it properly is non-negotiable.
I always follow this pattern:
class LoginViewModel : ViewModel() {
private val _loginState = MutableStateFlow<LoginState>(LoginState.Idle)
val loginState: StateFlow<LoginState> = _loginState.asStateFlow()
fun login(email: String, password: String) = viewModelScope.launch {
_loginState.value = LoginState.Loading
try {
val user = authRepository.login(email, password)
_loginState.value = LoginState.Success(user)
} catch (e: Exception) {
_loginState.value = LoginState.Error(e.message ?: "Unknown error")
}
}
}
// Test
@Test
fun testLoginSuccess() = runTest {
val viewModel = LoginViewModel(fakeAuthRepository)
viewModel.login("test@example.com", "password123")
advanceUntilIdle()
assertEquals(
LoginState.Success(testUser),
viewModel.loginState.value
)
}Key practice: Use runTest and advanceUntilIdle() when testing coroutines with StateFlow. This ensures your async code completes before assertions run.
💡 Pro Tip
Always inject your dependencies (repositories, data sources) into ViewModels. This makes testing trivial — swap real implementations with fakes. I reduced test flakiness by 80% at Raybit by enforcing strict dependency injection patterns.
Integration Testing Best Practices
Integration tests validate that your composables, ViewModels, and data sources work together. These are slower than unit tests, so use them strategically.
Testing Full Screens with Real State
For a complete screen test, I combine Compose's test rule with realistic state:
@Test
fun testLoginScreenFullFlow() {
composeTestRule.setContent {
val viewModel = LoginViewModel(fakeAuthRepository)
LoginScreen(viewModel = viewModel)
}
// User enters email
composeTestRule.onNodeWithTag("email_input")
.performTextInput("user@example.com")
// User enters password
composeTestRule.onNodeWithTag("password_input")
.performTextInput("password123")
// User clicks login
composeTestRule.onNodeWithText("Login").performClick()
// Wait for loading
composeTestRule.waitUntil(timeoutMillis = 5000) {
composeTestRule.onAllNodesWithText("Login").fetchSemanticsNodes().isEmpty()
}
// Verify success screen
composeTestRule.onNodeWithText("Welcome!").assertExists()
}This tests the actual user journey. When I did this at CodeBrew Labs, we caught edge cases that unit tests missed — like loading state not clearing properly when the user navigated back.
Real-World Testing Example
Let me share a concrete example from the AI NoteTaker project I built. We had a note list screen that showed user notes with sync status. Here's how I tested it comprehensively:
// ViewModel
class NoteListViewModel(
private val noteRepository: NoteRepository
) : ViewModel() {
private val _notes = MutableStateFlow<List<Note>>(emptyList())
val notes: StateFlow<List<Note>> = _notes.asStateFlow()
init {
loadNotes()
}
private fun loadNotes() = viewModelScope.launch {
_notes.value = noteRepository.getAllNotes()
}
fun deleteNote(noteId: String) = viewModelScope.launch {
noteRepository.deleteNote(noteId)
_notes.value = _notes.value.filter { it.id != noteId }
}
}
// Unit Test
@Test
fun testDeleteNoteRemovesFromList() = runTest {
val fakeRepo = FakeNoteRepository()
val viewModel = NoteListViewModel(fakeRepo)
val note = Note(id = "1", title = "Test")
fakeRepo.addNote(note)
advanceUntilIdle()
viewModel.deleteNote("1")
advanceUntilIdle()
assertTrue(viewModel.notes.value.isEmpty())
}
// Compose UI Test
@Test
fun testNoteListDisplaysNotes() {
composeTestRule.setContent {
val viewModel = NoteListViewModel(fakeNoteRepository)
NoteListScreen(viewModel = viewModel)
}
composeTestRule.onNodeWithText("Test Note 1").assertIsDisplayed()
composeTestRule.onNodeWithText("Test Note 2").assertIsDisplayed()
}
// Integration Test
@Test
fun testDeleteNoteFromUI() {
composeTestRule.setContent {
val viewModel = NoteListViewModel(fakeNoteRepository)
NoteListScreen(viewModel = viewModel)
}
composeTestRule.onNodeWithText("Delete").performClick()
composeTestRule.onNodeWithText("Test Note 1").assertDoesNotExist()
}This three-level approach caught bugs that would have shipped to production. When I reviewed crash reports from our earlier apps, 40% could have been prevented with proper integration testing like this.
⚠️ Common Pitfall
Don't test implementation details. If you're checking internal StateFlow emissions or private function calls, you're testing wrong. Test behavior — what users see and interact with. This keeps your tests stable as your Android architecture evolves.
Key Takeaways
- Compose changes Android testing. Traditional Espresso patterns are obsolete for Compose UI. Use
ComposeTestRuleand semantic matchers instead. - Layer your tests strategically. 70% unit tests (ViewModel/business logic), 20% Compose UI tests, 10% integration tests. This ratio maximizes coverage while keeping execution time reasonable.
- Inject dependencies relentlessly. When your ViewModel depends on repositories, inject fakes in tests. This makes testing trivial and your Android architecture testable by design.
- Test behavior, not implementation. Use text, tags, and semantics — not internal state checks. Your tests become resilient to refactoring.
- Integration tests are your insurance policy. They're slower but catch edge cases unit tests miss. Use them for critical user flows like authentication and payment.