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…
A photo showing code in an IDE, unrelated to anything discussed here

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:

  1. A ComposeContentTestRule for all your Compose testing needs
  2. Access to the Activity so Espresso can still find traditional Views
  3. 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:

  1. Compose welcome screen
  2. Compose login form
  3. View-based map screen
  4. View-based bottom navigation
  5. Compose profile screen
  6. Compose settings screen
  7. 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:

  1. Remove the Espresso dependencies
  2. Delete the Espresso extension functions
  3. Keep the page objects (just remove the View-based elements)
  4. 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:

  1. Use createAndroidComposeRule to bridge both worlds
  2. Create extension functions for API consistency
  3. Use page objects to hide framework complexity
  4. 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!