Jetpack Compose Testing: Complete Guide (2026)

Jetpack Compose's test framework is a joy. Tree semantics, stable locators via semantics modifier, fast execution. If you are on Compose, you have better testing tools than most. This guide covers the

January 30, 2026 · 3 min read · Testing Guides

Jetpack Compose's test framework is a joy. Tree semantics, stable locators via semantics modifier, fast execution. If you are on Compose, you have better testing tools than most. This guide covers the patterns that make testing Compose apps productive.

The test pyramid

  1. Unit tests: state, business logic, ViewModel
  2. Composable tests: individual composables via composeTestRule
  3. Instrumented tests: multiple composables integrated
  4. UI tests (Espresso via Compose): full flows

Compose's composable tests run in the JVM via Robolectric, no device needed. Fastest UI-adjacent tests in Android history.

Composable tests


import androidx.compose.ui.test.*

class LoginScreenTest {
    @get:Rule val composeTestRule = createComposeRule()

    @Test
    fun submit_button_calls_viewmodel() {
        var submitted = false
        composeTestRule.setContent {
            LoginScreen(onSubmit = { _, _ -> submitted = true })
        }
        composeTestRule.onNodeWithTag("email_field").performTextInput("test@example.com")
        composeTestRule.onNodeWithTag("password_field").performTextInput("pass")
        composeTestRule.onNodeWithText("Sign In").performClick()
        assertTrue(submitted)
    }
}

Locators (semantics)

Semantics modifier makes composables testable:


TextField(
    value = email, onValueChange = setEmail,
    modifier = Modifier.testTag("email_field")
)

Use testTag for locators. Do not use onNodeWithText — text changes with i18n.

ViewModel tests

State hoisted out of composable → view model is testable in pure JVM:


class LoginViewModelTest {
    @Test
    fun `login with valid credentials transitions to authenticated`() = runTest {
        val repo = FakeAuthRepo(success = true)
        val vm = LoginViewModel(repo)
        vm.submit("test@example.com", "correct")
        advanceUntilIdle()
        assertEquals(LoginState.Authenticated, vm.state.value)
    }
}

Screenshot tests (Paparazzi, shot)

Paparazzi renders composables in JVM; compare to golden images.


class LoginScreenshotTest {
    @get:Rule val paparazzi = Paparazzi()

    @Test fun login_default() {
        paparazzi.snapshot { LoginScreen(viewModel = previewViewModel()) }
    }
    @Test fun login_error() {
        paparazzi.snapshot { LoginScreen(viewModel = previewViewModel(error = "Invalid")) }
    }
}

Fast, deterministic, cross-platform.

UI tests (Espresso)

composeTestRule inside an Espresso test:


@HiltAndroidTest
class LoginFlowTest {
    @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
    @get:Rule(order = 1) val composeTestRule = createAndroidComposeRule<MainActivity>()

    @Test
    fun successfulLogin() {
        composeTestRule.onNodeWithTag("email").performTextInput("test@example.com")
        composeTestRule.onNodeWithTag("password").performTextInput("correct")
        composeTestRule.onNodeWithText("Sign In").performClick()
        composeTestRule.onNodeWithText("Welcome").assertIsDisplayed()
    }
}

Custom matchers


fun SemanticsNodeInteraction.assertIsEnabled() {
    assert(isEnabled())
}
fun isEnabled(): SemanticsMatcher = SemanticsMatcher.expectValue(SemanticsProperties.Disabled, false)

Build domain-specific matchers for cleaner tests.

Async / coroutines

Compose tests auto-await idle state. For suspend functions in tests, use runTest:


@Test fun myTest() = runTest {
    ...
}

Advance dispatchers if needed:


advanceUntilIdle()  // flushes pending coroutines

Accessibility in Compose

Semantics are accessibility:


Button(
    onClick = onSubmit,
    modifier = Modifier.semantics {
        contentDescription = "Submit login form"
        role = Role.Button
    }
) { Text("Submit") }

Tests cover accessibility properties directly:


composeTestRule.onNodeWithContentDescription("Submit login form").assertIsDisplayed()

Run in CI

Unit + composable tests in Gradle task test. UI tests in connectedAndroidTest (requires emulator / device).


- run: ./gradlew test                    # fast
- run: ./gradlew connectedDebugAndroidTest  # slower, gates release

How SUSA tests Compose apps

SUSA drives Compose apps via Android's accessibility layer. If composables have proper contentDescription / testTag, SUSA can find and interact. Generated Appium scripts use the same semantics.


susatest-agent test composeapp.apk --persona curious --steps 200

Best practices

  1. Test ViewModel state, not composable rendering. Fast, deterministic.
  2. Use testTag, not text. Stable across i18n.
  3. Paparazzi for visual regression. Screenshot tests without a device.
  4. Compose test rule for composable integration. Fast feedback.
  5. XCUITest-equivalent (Espresso + Compose) for critical flows only.

Compose's test framework is the best in any UI framework in 2026. Use it fully; ship confidently.

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