Jetpack Compose Testing: What Changed and What Broke

The shift from Android's traditional View system to Jetpack Compose represents a seismic change in UI development. While the benefits of declarative UI are undeniable – cleaner code, faster iteration,

May 18, 2026 · 13 min read · Framework

Jetpack Compose Testing: What Broke and What We Built to Fix It

The shift from Android's traditional View system to Jetpack Compose represents a seismic change in UI development. While the benefits of declarative UI are undeniable – cleaner code, faster iteration, and a more intuitive development experience – the testing paradigm has undergone a similar, and often more disruptive, transformation. For teams accustomed to the robust, albeit verbose, Espresso framework, navigating Compose's testing landscape can feel like landing on an alien planet. This article dives deep into the fundamental differences, the common pitfalls, and the strategies we've developed at SUSA to ensure robust, maintainable test suites for Compose-based applications. We'll move beyond the surface-level "how-to" and explore the "why" behind Compose's testing model, equipping you with the knowledge to build resilient tests and avoid the common traps that can derail your QA efforts.

The Fundamental Divergence: State-Driven vs. View-Hierarchy Driven

Espresso's testing model is intrinsically tied to the View hierarchy. Its core mechanism relies on locating View objects within the active activity's window, asserting their properties, and performing actions on them. This works because the View system is inherently imperative: you inflate a layout, find a Button, set its OnClickListener, and Espresso can traverse this tree to find and interact with that Button.

Compose, on the other hand, is declarative and state-driven. UI is a function of state. When state changes, Compose recomposes the UI. There's no persistent, inspectable View hierarchy in the same way. Instead, Compose builds a tree of Composables that describe the UI. Testing this requires a different approach, one that understands and interacts with this functional nature.

This fundamental difference manifests in several key areas:

Understanding this shift is the first step to demystifying Compose testing. It’s not just a syntax change; it’s a paradigm shift that necessitates a new mental model.

Introducing createComposeRule: Your Gateway to Compose Testing

The createComposeRule() function, part of the androidx.compose.ui.test artifact, is the cornerstone of Compose UI testing. It provides a ComposeTestRule that manages the Compose test environment. Unlike ActivityScenarioRule, which launches an Android Activity, createComposeRule hosts your composables in a test environment without a full Activity lifecycle. This is significantly faster and more resource-efficient for unit and integration tests.


// build.gradle (app level)
dependencies {
    // ... other dependencies
    androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.5.4") // Use the latest stable version
    androidTestImplementation("androidx.compose.ui:ui-test-manifest:1.5.4")
    androidTestImplementation("androidx.activity:activity-compose:1.8.1") // For Activity-backed Compose
}

A typical test file would look like this:


package com.example.myapp.ui.tests

import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import com.example.myapp.ui.screens.MyScreen // Your composable screen
import org.junit.Rule
import org.junit.Test

class MyScreenTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun myScreen_displaysGreeting() {
        // Set the content of the screen to the composable being tested.
        composeTestRule.setContent {
            MyScreen("Android")
        }

        // Assert that the greeting text is displayed.
        composeTestRule.onNodeWithText("Hello Android!").assertIsDisplayed()
    }

    @Test
    fun myScreen_clickButton_updatesText() {
        composeTestRule.setContent {
            MyScreen("User")
        }

        // Find the button and click it.
        composeTestRule.onNodeWithText("Change Greeting").performClick()

        // Assert that the greeting text has been updated.
        composeTestRule.onNodeWithText("Hello Updated User!").assertIsDisplayed()
    }
}

The setContent lambda is crucial. It's where you define the composable hierarchy that the test rule will render. This allows for fine-grained control over what is being tested, isolating components effectively.

The SemanticsNode: Compose's Inspectable Element

If View was Espresso's tangible entity, the SemanticsNode is Compose's. A SemanticsNode is not a UI element itself but rather a representation of the *semantics* of a UI element. These semantics are crucial for accessibility and testing. They describe what an element *is* and what it *does*.

When you write composables, Compose automatically generates a semantics tree. You can augment this tree using modifiers like semantics and testTag.


    Text(
        text = "Close button",
        modifier = Modifier.semantics { contentDescription = "Close the current view" }
    )

    Button(
        onClick = { /* ... */ },
        modifier = Modifier.testTag("login_button")
    ) {
        Text("Login")
    }

You can then query for these nodes using onNode() with various matchers:

The SemanticsNodeInteraction returned by these matchers allows you to perform actions and make assertions.

Assertions and Actions: The Language of Interaction

Interacting with and asserting on SemanticsNodes is done through SemanticsNodeInteraction objects.

Common Assertions:

Common Actions:


composeTestRule.onNodeWithTag("username_input").performTextInput("testuser")
composeTestRule.onNodeWithTag("password_input").performTextInput("password123")
composeTestRule.onNodeWithText("Login").performClick()

composeTestRule.onNodeWithText("Welcome, testuser!").assertIsDisplayed()

The Deterministic Frame Model: A Double-Edged Sword

Compose's rendering is based on a frame-by-frame model. When state changes, Compose schedules a recomposition and subsequent rendering. The testing framework is designed to wait for these frames to complete. This deterministic nature is a significant improvement over the asynchronous callbacks and potential race conditions that could plague Espresso tests.

The test rule will automatically wait for UI updates to settle. This means you generally don't need explicit delays or Thread.sleep() calls, which were common (and problematic) in older Android testing. The framework intelligently waits for the UI to be in a stable state before proceeding with the next assertion or action.

However, this deterministic model introduces its own set of challenges, particularly with animations and complex recompositions.

#### The Animation Timing Gotcha

Animations in Compose are not an afterthought; they are an integral part of the UI. While the deterministic frame model handles static UI updates well, animations introduce a temporal element that tests must account for.

By default, the ComposeTestRule waits for a short duration after an action to ensure UI stability. However, long-running animations might exceed this default timeout. If your test asserts on a UI state that is still animating, it might fail intermittently.

Strategies for Handling Animations:

  1. Disable Animations (for tests): The simplest approach is to disable animations during testing. This is often achievable by setting a system property or using a specific theme.

    // In your test's @Before or setup method
    composeTestRule.activityRule.scenario.onActivity { activity ->
        activity.window.setAnimationsEnabled(false)
    }

*Note: This requires activityRule which means using createAndroidComposeRule instead of createComposeRule for activity-backed Compose tests.*

  1. Use waitForIdle(): While the rule usually waits automatically, waitForIdle() can be used to explicitly wait for the UI to be stable, which can sometimes help with animations settling.

    composeTestRule.waitForIdle()
  1. Test Animation States Explicitly: For tests that specifically target animations, you might need to wait for a specific duration or check animation states. Compose provides APIs for Animatable and Transition that can be inspected.

    // Example: waiting for a specific animation to complete (simplified)
    composeTestRule.apply {
        // ... perform action that starts animation ...
        // This is a simplified example; real-world might involve inspecting animation state
        Thread.sleep(500) // Not ideal, but demonstrates the concept of waiting for animation
        onNodeWithText("Animated State").assertIsDisplayed()
    }

*It’s crucial to note that Thread.sleep is generally discouraged. Instead, look for ways to query the animation's state or use libraries that provide utilities for testing animations.*

  1. ComposeAnimation utilities: Libraries like compose-animation-test (external) or custom extensions can help. However, Compose itself is evolving its testing utilities for animations. Keep an eye on official Compose releases for improved animation testing support.

The SUSA platform, for instance, incorporates strategies to handle animations by understanding the visual changes across frames, rather than just waiting for a fixed duration. It can detect when an animation has reached its target state by comparing visual snapshots or semantic tree changes over time.

The Recomposition Loop Nightmare

Jetpack Compose's reactivity is powerful, but it can lead to infinite recomposition loops if not managed carefully. A recomposition loop occurs when a state change triggers a recomposition, which in turn triggers another state change, leading to an endless cycle.


var count by remember { mutableStateOf(0) }

Button(onClick = { count++ }) {
    Text("Increment")
}

// Problematic: This Text recomposes and updates 'count' on every render
Text("Current count: $count") // If this Text's lambda indirectly causes count to change

In the View system, such an issue might manifest as a frozen UI or an ANR. In Compose, it often leads to the UI thread being saturated, causing performance degradation and potentially ANRs, but the testing framework itself might also struggle.

Detecting Recomposition Loops in Tests:

Preventing Recomposition Loops:

  1. remember correctly: Ensure that state that shouldn't cause recomposition on every render is properly remembered.
  2. Stable Types: Use stable data classes or mark your types as @Stable if they are mutable but their changes should not trigger recompositions unless explicitly intended.
  3. derivedStateOf: For values derived from other state, use derivedStateOf to optimize recompositions. It only recalculates when the underlying state actually changes.
  4. Keying Composables: For lists or repeated composables, provide stable keys using key() to help Compose efficiently update the UI.

The SUSA platform, with its ability to monitor application behavior over extended periods and analyze performance metrics, can often flag applications exhibiting symptoms of recomposition loops even before they manifest as outright test failures.

Preview Differences: The "It Works on My Machine" Syndrome

Compose Previews are a fantastic feature for rapid UI iteration. However, the preview environment is not identical to a running device or emulator. This can lead to the dreaded "It works on my machine" scenario where UI looks and behaves differently in a preview compared to a real test run.

Common Discrepancies:

Bridging the Gap:

  1. Test with createComposeRule: Always validate your UI and interactions using createComposeRule() or createAndroidComposeRule(). Previews are for design and quick iteration, not for definitive testing.
  2. Use @PreviewParameter: For testing different states or data variations in previews, use @PreviewParameter to inject diverse data.
  3. Mocking Dependencies: In your tests (and potentially in previews where applicable), mock any external dependencies (network calls, databases) to ensure consistent and predictable behavior.
  4. Theme Awareness: Ensure your composables correctly apply themes. If your preview uses a custom theme, make sure your tests also apply that same theme.

// MyScreen.kt
@Composable
fun MyScreen(name: String) {
    Column {
        Text("Hello, $name!")
        Button(onClick = { /* ... */ }) {
            Text("Click Me")
        }
    }
}

@Preview(showBackground = true)
@Composable
fun MyScreenPreview() {
    MyScreen("World")
}

// MyScreenTest.kt
class MyScreenTest {
    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun myScreen_displaysCorrectGreeting() {
        composeTestRule.setContent {
            MyScreen("Compose Tester")
        }
        composeTestRule.onNodeWithText("Hello, Compose Tester!").assertIsDisplayed()
    }
}

The SUSA platform executes tests in an environment that closely mirrors a real device, reducing the likelihood of preview-related discrepancies impacting test results.

Beyond Basic Interactions: Accessibility and Security

Compose's semantic tree isn't just for basic UI testing; it's a powerful tool for ensuring accessibility and security.

#### Accessibility Testing with Semantics

The semantics modifier is your best friend for accessibility. By correctly defining contentDescription, stateDescription, and other semantic properties, you make your app usable for everyone.


    // Example: Checking for missing content descriptions on buttons
    composeTestRule.onAllNodes(isClickable()).filterNot { it.hasAnyDescendant(hasContentDescription()) }
        .assertCountEquals(0) // Assert that there are no clickable nodes without content descriptions

    composeTestRule.onNode(hasStateDescription("Checked")).assertExists()

The SUSA platform has built-in checks for WCAG 2.1 AA accessibility violations, automatically flagging issues like missing contentDescription for interactive elements or insufficient color contrast (though color contrast often requires visual analysis or specialized tools).

#### Security Testing with Semantics

While Compose testing isn't a replacement for dedicated security testing tools, the semantics can expose potential vulnerabilities.


    // Example: Asserting that a "Delete" button is not displayed for a read-only user
    composeTestRule.onNodeWithText("Delete").assertDoesNotExist()

SUSA's autonomous QA capabilities extend to API contract validation. It can monitor API calls made by the application during exploration and compare them against predefined OpenAPI/Swagger contracts, flagging discrepancies that might indicate security vulnerabilities or integration issues.

Generating Regression Scripts: The Power of Autonomous Exploration

One of the most exciting aspects of modern QA platforms like SUSA is the ability to leverage AI and automated exploration to generate test cases. For Compose applications, this is particularly powerful because the exploration process can directly inform regression test generation.

When SUSA's personas explore your application, they interact with the UI in a manner driven by AI and user-like heuristics. During this exploration, the platform captures the sequence of actions performed, the UI states encountered, and the outcomes.

This captured data can then be used to automatically generate regression test scripts. For Compose, this means generating scripts that utilize the ComposeTestRule, SemanticsNodeInteraction, and the various matchers and actions we've discussed.

How it Works (Conceptual):

  1. Exploration: SUSA's autonomous agents navigate the app, discovering screens, features, and user flows. They interact with buttons, input fields, and other UI elements.
  2. Instrumentation: During exploration, the agents log their interactions. For Compose, this logging includes details about the SemanticsNodes they interact with (e.g., text, content description, test tag) and the actions performed (e.g., performClick, performTextInput).
  3. Script Generation: Based on these logs, SUSA can generate runnable test code. For example, an exploration log might show:

This log translates directly into a Compose test:


    // Generated by SUSA
    @Test
    fun generated_loginFlow_success() {
        composeTestRule.apply {
            onNodeWithText("Username").performTextInput("user123")
            onNodeWithText("Password").performTextInput("pass!")
            onNodeWithText("Login").performClick()
            onNodeWithText("Welcome, user123!").assertIsDisplayed()
        }
    }

This ability to auto-generate robust Compose tests from real user-like exploration significantly reduces the manual effort required to build and maintain a comprehensive regression suite. It also ensures that tests cover actual user flows rather than just isolated components.

Furthermore, SUSA's cross-session learning means that as the autonomous agents explore your app over time, they become more adept at identifying potential issues and generating more targeted and effective regression tests.

CI/CD Integration: Seamlessly Embedding Compose Tests

For Compose tests to be effective, they need to be integrated into your CI/CD pipeline. The androidx.compose.ui:ui-test-junit4 artifact ensures that Compose tests are standard JUnit4 tests, making integration straightforward.

Key Integration Points:


    name: Android CI

    on:
      push:
        branches: [ main ]
      pull_request:
        branches: [ main ]

    jobs:
      build:
        runs-on: ubuntu-latest
        steps:
        - uses: actions/checkout@v3
        - name: Set up JDK 17
          uses: actions/setup-java@v3
          with:
            java-version: '17'
            distribution: 'temurin'
            cache: gradle

        - name: Grant execute permission for gradlew
          run: chmod +x gradlew

        - name: Run Instrumentation Tests
          run: ./gradlew connectedCheck --tests com.example.myapp.ui.tests.*

        # Optional: Upload test results (e.g., JUnit XML)
        - name: Upload JUnit Test Results
          uses: actions/upload-artifact@v3
          with:
            name: junit-results
            path: app/build/reports/androidTests/connected/

    susa upload --app-path ./app/build/outputs/apk/debug/app-debug.apk --platform android
    susa explore --app-id <your_app_id> --personas "10"
    susa results --app-id <your_app_id> --format junit > results.xml

This seamless integration ensures that your Compose tests are run automatically on every code change, providing rapid feedback and catching regressions early.

The "Gotchas" Nobody Warns You About (Summary and How SUSA Helps)

Let's recap some of the trickiest aspects of Compose testing and how a platform like SUSA can mitigate them:

GotchaDescription

Test Your App Autonomously

Upload your APK or URL. SUSA explores like 10 real users — finds bugs, accessibility violations, and security issues. No scripts.

Try SUSA Free