Testing Android Apps During the Compose Migration: A Practical Guide to Mixing Espresso and…
How to write UI tests when your app is half traditional Views and half Jetpack Compose — without losing your mind
Testing Android Apps During the Compose Migration: A Practical Guide to Mixing Espresso and Compose Test
The Problem Nobody Talks About
When I started searching for how to test an Android app that uses both traditional Views and Jetpack Compose, I found dozens of blog posts. Some showed beautiful Espresso tests for View-based UIs. Others demonstrated elegant Compose tests with semantic matchers. But none — and I mean none — showed how to test a screen where a View-based bottom navigation bar launches a Compose-based settings screen.
This is the reality for most Android teams right now. Your app didn’t magically become 100% Compose overnight. You’re migrating screen by screen, feature by feature. Your sign-in flow might use traditional EditTexts while your new profile screen is pure Compose. Your bottom navigation is still a BottomNavigationView but the destinations are ComposeView containers.
And your tests? They need to work with both.
After reading three different blog posts that sort of alluded to mixing these frameworks, lots of trial and error, and more than a few “why isn’t this working?!” moments, I figured out a solution. This is the guide I wish I’d found when I started.
The Core Challenge: Two Different Testing Frameworks
Let’s start with why this is hard.
Espresso: The Traditional Approach
Espresso tests interact with Android Views — Button, TextView, EditText, etc. You find elements by resource ID:
onView(withId(R.id.my_button))
.perform(click())Compose Test: The New Way
Compose tests interact with semantic nodes — the accessibility tree that Compose builds. You find elements by test tags, text, or content descriptions:
composeTestRule.onNodeWithTag("my_button")
.performClick()The Problem
These are completely separate testing frameworks. They don’t know about each other. Espresso can’t find Compose nodes. Compose Test can’t find Views. And when you try to use both in the same test? Chaos.
Or at least, that’s what every example online led me to believe.
The “Aha!” Moment: createAndroidComposeRule
Here’s the key insight that unlocked everything:
createAndroidComposeRule gives you access to both worlds.
Most Compose testing examples use createComposeRule(), which only works for isolated Compose UIs. But if your app is a real Activity-based application (which it probably is), you need createAndroidComposeRule:
@get:Rule
val composeTestRule = createAndroidComposeRule<WelcomeActivity>()This single line gives you:
- A ComposeContentTestRule for all your Compose testing needs
- Access to the Activity so Espresso can still find traditional Views
- A bridge between both frameworks
Suddenly, you can do this in the same test:
// Interact with Compose UI
composeTestRule.onNodeWithTag("login_button").performClick()
// Interact with View UI
onView(withId(R.id.map_button)).perform(click())They just… work together. No special configuration. No hacks.
Making It Elegant: Extension Functions to the Rescue
Now that we can use both frameworks, the next challenge is making the code readable. Having two completely different APIs in the same test file is jarring:
// Espresso style
onView(withId(R.id.button)).perform(click())
// Compose style
composeTestRule.onNodeWithTag("button").performClick()One uses perform(click()), the other uses performClick(). One uses withId(), the other uses onNodeWithTag(). It's inconsistent and hard to read.
Solution: Extension Functions for Symmetry
I created extension functions that make both APIs feel similar:
// Extension on Int (resource IDs) for Espresso
@IdRes
fun Int.clickButton() {
onView(
allOf(
withId(this),
isDisplayed(),
isClickable()
)
).check(matches(isDisplayed()))
.perform(click())
}
@IdRes
fun Int.buttonShown(): Boolean {
return try {
onView(
allOf(
withId(this),
isDisplayed(),
isClickable()
)
).check(matches(isDisplayed()))
true
} catch (e: NoMatchingViewException) {
false
}
}Now Espresso calls look clean:
R.id.my_button.clickButton()
if (R.id.cta_button.buttonShown()) {
R.id.cta_button.clickButton()
}Parallel Extension for Compose
To create symmetry, I added a similar extension for Compose:
// Extension on SemanticsNodeInteraction for Compose
fun SemanticsNodeInteraction.nodeExists(): Boolean {
return try {
fetchSemanticsNode()
true
} catch (e: AssertionError) {
false
} catch (e: Exception) {
false
}
}Now both frameworks use a similar API:
// Espresso
if (R.id.button.buttonShown()) { ... }
// Compose
if (composeTestRule.onNodeWithTag("button").nodeExists()) { ... }Much better!
Page Object Pattern: Mixing Both Frameworks Gracefully
Page objects are a classic pattern for organizing test code, and they work beautifully for hybrid UIs. Here’s how I structure them:
class SignInOutUp(
private val composeTestRule: ComposeContentTestRule,
private val reportHelper: ReportHelper,
private val mapPage: Map
) {
// Compose UI elements - use semantic finders
val usernameField = composeTestRule.onNodeWithTag("Username_Text_Entry")
val passwordField = composeTestRule.onNodeWithTag("Password_Text_Entry")
val loginSubmitButton = composeTestRule.onNodeWithTag("Log_In_Button")
val welcome_screen_loginButton = composeTestRule.onNodeWithTag("Welcome_Login_Button")
// View UI elements - use resource IDs
val ctaButton = R.id.button_cta
fun signIn() {
// Interact with Compose UI
welcome_screen_loginButton.performClick()
// Input credentials (Compose fields)
usernameField.performTextInput("myusername")
passwordField.performTextInput("mypassword")
loginSubmitButton.performClick()
// Wait for View-based UI to appear
while (ctaButton.buttonShown()) {
ctaButton.clickButton()
Thread.sleep(1000)
}
}
}The key insight: You can mix both types of UI elements in the same page object. The calling test doesn’t need to know or care which framework is being used under the hood.
Synchronization: The Tricky Part
Here’s where things get subtle. When you interact with View UI and then need to check Compose UI (or vice versa), you need to give the frameworks time to synchronize.
The waitForIdle() Pattern
After Espresso performs an action that might trigger Compose recomposition, call composeTestRule.waitForIdle():
fun navigateToProfile() {
profileButton.clickButton() // Espresso action
composeTestRule.waitForIdle() // Let Compose catch up
}This tells Compose to wait until all pending recompositions and animations complete before proceeding.
Custom Wait Utilities
For more complex scenarios, I created a reusable wait utility:
fun ComposeContentTestRule.waitUntilNodeExists(
node: SemanticsNodeInteraction,
timeoutMillis: Long = 5000
) {
this.waitUntil(timeoutMillis) {
node.nodeExists()
}
}Usage:
fun goToSettings() {
settingsButton.performClick()
// Wait for settings content to load (not just the spinner)
composeTestRule.waitUntilNodeExists(
composeTestRule.onNodeWithTag("settings_content_loaded"),
timeoutMillis = 15000
)
}This prevents flaky tests by waiting for specific UI conditions rather than arbitrary timeouts.
Real-World Example: A Hybrid Sign-In Flow
Let’s look at a complete example that ties everything together. This test exercises a sign-in flow that uses:
- Compose UI for the welcome screen and login form
- View-based UI for the bottom navigation and map screen
- Mixed UI for the settings flow
class SignInOutUpTest : WelcomeScreenTestBase() {
val mapPage = Map(composeTestRule, reportHelper)
val signInOutUpPage = SignInOutUp(composeTestRule, reportHelper, mapPage)
@Test
fun canSignInAndSignOut() {
// Sign in using Compose login form
signInOutUpPage.signIn()
// Verify we're on the map screen (View-based UI)
assertTrue { mapPage.mapTabActive() }
// Navigate using View-based bottom nav to Compose profile screen
mapPage.navigateToProfile()
// Sign out from Compose settings screen
signInOutUpPage.signOut()
// Verify we're back at Compose welcome screen
assertTrue {
signInOutUpPage.welcome_screen_loginButton.nodeExists()
}
}
}The test seamlessly transitions between:
- Compose welcome screen
- Compose login form
- View-based map screen
- View-based bottom navigation
- Compose profile screen
- Compose settings screen
- Back to Compose welcome screen
All in a single test, using a consistent API.
Bonus: Handling System Dialogs with UiAutomator
There’s a third framework you might need: UiAutomator. Why? Because neither Espresso nor Compose can interact with system-level dialogs (like the Google Password Manager popup we discussed in a previous post).
Here’s how to integrate it:
fun dismissGooglePasswordManagerIfPresent() {
val uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
// Look for password manager dialog
val passwordDialog = uiDevice.findObject(
UiSelector().resourceIdMatches(".*touch_outside.*")
)
val savePasswordDialog = uiDevice.findObject(
UiSelector().textMatches(".*(Save|save).*password.*")
)
if (passwordDialog.exists() || savePasswordDialog.exists()) {
// Tap above the dialog to dismiss
uiDevice.click(uiDevice.displayWidth / 2, uiDevice.displayHeight / 4)
Thread.sleep(500)
}
}Now your test can handle:
- Espresso for traditional Views
- Compose Test for Compose UI
- UiAutomator for system dialogs
All three frameworks, one test, no problem.
Common Pitfalls and How to Avoid Them
Pitfall 1: Using createComposeRule() Instead of createAndroidComposeRule()
Wrong:
@get:Rule
val composeTestRule = createComposeRule()Right:
@get:Rule
val composeTestRule = createAndroidComposeRule<YourActivity>()The first only works for isolated Compose UIs. The second works for real apps with Activities.
Pitfall 2: Forgetting to Synchronize After View Actions
If you click an Espresso View that triggers Compose recomposition, always call waitForIdle():
espressoButton.clickButton()
composeTestRule.waitForIdle() // Don't forget this!
composeTestRule.onNodeWithTag("result").assertExists()Pitfall 3: Assuming Resource IDs Work in Compose
Compose doesn’t use resource IDs for UI elements. Use test tags instead:
// In your Compose UI
Button(
modifier = Modifier.testTag("login_button"),
onClick = { }
) {
Text("Log In")
}
// In your test
composeTestRule.onNodeWithTag("login_button").performClick()Pitfall 4: Not Handling Loading States
Many hybrid UIs have loading spinners (often Compose) before content appears (often Views). Always wait for the actual content:
// Bad - might check too early
composeTestRule.waitForIdle()
// Good - wait for specific content
composeTestRule.waitUntilNodeExists(
composeTestRule.onNodeWithTag("content_loaded")
)The Test Architecture That Emerged
After solving these challenges, here’s the testing architecture I landed on:
1. Base Test Classes
open class WelcomeScreenTestBase : TestBase() {
@get:Rule
val composeTestRule = createAndroidComposeRule<WelcomeActivity>()
}2. Page Objects (mixed View and Compose)
class ProfilePage(private val composeTestRule: ComposeContentTestRule) {
// Compose elements
private val settingsButton = composeTestRule.onNodeWithContentDescription("Settings")
fun goToSettings() {
composeTestRule.waitUntilNodeExists(settingsButton)
settingsButton.performClick()
composeTestRule.waitUntilNodeExists(
composeTestRule.onNodeWithTag("settings_content_loaded"),
timeoutMillis = 15000
)
}
}3. Helper Utilities (extension functions)
// EspressoHelper.kt
fun Int.clickButton() { ... }
fun Int.buttonShown(): Boolean { ... }
fun SemanticsNodeInteraction.nodeExists(): Boolean { ... }
fun ComposeContentTestRule.waitUntilNodeExists(...) { ... }
fun dismissGooglePasswordManagerIfPresent() { ... }4. Tests (clean and readable)
@Test
fun canSignInTest() {
signInOutUpPage.signIn()
assertTrue { mapPage.mapTabActive() }
}This architecture scales beautifully as you migrate more screens to Compose. Add new page objects, use the same utilities, write clean tests.
Lessons Learned
After implementing this approach across dozens of tests, here’s what I learned:
1. The migration doesn’t have to block testing
You don’t need to wait until everything is Compose to write good tests. Hybrid testing works.
2. Consistency matters more than purity
It’s tempting to try to “convert everything to Compose Test” or “keep using Espresso for everything.” Don’t. Use the right tool for each UI component. The consistency comes from your API layer (page objects and extension functions), not from using a single framework.
3. Synchronization is the hardest part
99% of flaky tests in hybrid UIs come from synchronization issues. Always wait for specific UI states, never use arbitrary Thread.sleep() (except as a last resort).
4. Page objects are your friend
They hide the complexity of which framework you’re using. Tests become declarations of intent, not framework-specific implementation details.
5. Extension functions level the playing field
By creating symmetric APIs for both frameworks, you reduce cognitive load. Developers writing tests don’t need to context-switch between “Espresso mode” and “Compose mode.”
What About the Future?
Eventually, your app will be 100% Compose. At that point, you can:
- Remove the Espresso dependencies
- Delete the Espresso extension functions
- Keep the page objects (just remove the View-based elements)
- Keep the Compose utilities (they’re still useful)
The migration path is smooth because the architecture doesn’t depend on using both frameworks — it just supports it.
Resources and References
If you’re diving into hybrid testing, these resources helped me:
Official Documentation
Key APIs to Know
- createAndroidComposeRule<Activity>() - Your gateway to hybrid testing
- ComposeContentTestRule.waitForIdle() - Synchronization between frameworks
- SemanticsNodeInteraction.fetchSemanticsNode() - Check if Compose nodes exist
- UiDevice.getInstance() - Access to system-level interactions
Conclusion: You Can Do This
Testing a hybrid View/Compose app feels overwhelming at first. The documentation acts like you’re either all-in on Compose or still in the Espresso world. The examples don’t show real-world complexity. The error messages are cryptic.
But once you understand the core concepts:
- Use createAndroidComposeRule to bridge both worlds
- Create extension functions for API consistency
- Use page objects to hide framework complexity
- Always synchronize with waitForIdle() or custom waits
…it all clicks into place.
Your tests can be clean, maintainable, and actually work reliably. You can test your app during the migration, not after. And when you finally finish migrating to Compose, your test architecture gracefully evolves with minimal refactoring.
I spent weeks figuring this out through trial and error. Hopefully, this guide saves you that time. Now go write some tests!