---
name: compose-testing
description: "Test Jetpack Compose UI with createComposeRule, semantic matchers (onNodeWithText, onNodeWithTag, onNodeWithContentDescription), actions (performClick, performTextInput, performScrollTo), assertions (assertIsDisplayed, assertExists, assertTextEquals), screenshot testing with Roborazzi or Paparazzi, ViewModel testing with Turbine, TestDispatcher, and runTest patterns. Use when writing Compose UI tests, ViewModel tests, or screenshot tests."
---

# Compose Testing

Testing patterns for Jetpack Compose covering UI tests with ComposeTestRule,
ViewModel tests with Turbine and TestDispatcher, and screenshot tests with
Roborazzi/Paparazzi. Targets 2024-2025 testing libraries and Kotlin coroutines
test APIs.

## Contents

- [ComposeTestRule Setup](#composetestrule-setup)
- [Finding Nodes](#finding-nodes)
- [Performing Actions](#performing-actions)
- [Assertions](#assertions)
- [Testing State Changes](#testing-state-changes)
- [ViewModel Testing with Turbine](#viewmodel-testing-with-turbine)
- [TestDispatcher and runTest](#testdispatcher-and-runtest)
- [Screenshot Testing](#screenshot-testing)
- [Do's and Don'ts](#dos-and-donts)
- [Troubleshooting](#troubleshooting)
- [Review Checklist](#review-checklist)

## ComposeTestRule Setup

### Unit Tests (No Activity)

```kotlin
class ButtonComponentTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun primaryButton_displaysLabel() {
        composeTestRule.setContent {
            AppTheme {
                PrimaryButton(label = "Book Flight", onClick = {})
            }
        }

        composeTestRule
            .onNodeWithText("Book Flight")
            .assertIsDisplayed()
    }
}
```

### Instrumented Tests (With Activity)

```kotlin
@HiltAndroidTest
class HomeScreenTest {

    @get:Rule(order = 0)
    val hiltRule = HiltAndroidRule(this)

    @get:Rule(order = 1)
    val composeTestRule = createAndroidComposeRule<MainActivity>()

    @Before
    fun setup() {
        hiltRule.inject()
    }

    @Test
    fun homeScreen_showsTitle() {
        composeTestRule
            .onNodeWithText("Home")
            .assertIsDisplayed()
    }
}
```

## Finding Nodes

### Semantic Matchers

```kotlin
// By text content
composeTestRule.onNodeWithText("Book Flight")
composeTestRule.onNodeWithText("book flight", ignoreCase = true)
composeTestRule.onNodeWithText("Flight", substring = true)

// By test tag (set via Modifier.testTag("tag"))
composeTestRule.onNodeWithTag("flight_list")

// By content description (accessibility)
composeTestRule.onNodeWithContentDescription("Search flights")

// Multiple nodes
composeTestRule.onAllNodesWithText("Select")
composeTestRule.onAllNodesWithTag("flight_card")

// Combined matchers
composeTestRule.onNode(
    hasText("Book") and hasClickAction()
)

// Parent/child traversal
composeTestRule
    .onNodeWithTag("flight_card")
    .onChildren()
    .filterToOne(hasText("IST"))
```

### Setting Test Tags

```kotlin
@Composable
fun FlightCard(flight: Flight, modifier: Modifier = Modifier) {
    Card(
        modifier = modifier.testTag("flight_card_${flight.id}"),
    ) {
        Text(
            text = flight.origin,
            modifier = Modifier.testTag("flight_origin"),
        )
    }
}
```

## Performing Actions

```kotlin
// Click
composeTestRule.onNodeWithText("Book").performClick()

// Text input
composeTestRule.onNodeWithTag("search_field").performTextInput("Istanbul")

// Clear and replace text
composeTestRule.onNodeWithTag("search_field").performTextClearance()
composeTestRule.onNodeWithTag("search_field").performTextReplacement("Ankara")

// Scroll to node (in scrollable container)
composeTestRule.onNodeWithText("Last Item").performScrollTo()

// Scroll to index in LazyColumn
composeTestRule.onNodeWithTag("flight_list").performScrollToIndex(15)

// Scroll to key in LazyColumn
composeTestRule.onNodeWithTag("flight_list").performScrollToKey("flight-42")

// Swipe gestures
composeTestRule.onNodeWithTag("card").performTouchInput {
    swipeLeft()
    swipeRight()
    swipeUp()
    swipeDown()
}

// Long click
composeTestRule.onNodeWithTag("item").performTouchInput {
    longClick()
}
```

## Assertions

```kotlin
// Existence and visibility
composeTestRule.onNodeWithText("Title").assertIsDisplayed()
composeTestRule.onNodeWithText("Title").assertExists()
composeTestRule.onNodeWithText("Hidden").assertDoesNotExist()
composeTestRule.onNodeWithText("Hidden").assertIsNotDisplayed()

// Text content
composeTestRule.onNodeWithTag("price").assertTextEquals("$299")
composeTestRule.onNodeWithTag("price").assertTextContains("299")

// Enabled/disabled state
composeTestRule.onNodeWithText("Submit").assertIsEnabled()
composeTestRule.onNodeWithText("Submit").assertIsNotEnabled()

// Selection state
composeTestRule.onNodeWithText("Economy").assertIsSelected()
composeTestRule.onNodeWithText("Business").assertIsNotSelected()

// Toggle state
composeTestRule.onNodeWithTag("wifi_toggle").assertIsOn()
composeTestRule.onNodeWithTag("wifi_toggle").assertIsOff()

// Count assertions
composeTestRule.onAllNodesWithTag("flight_card").assertCountEquals(5)

// Focused state
composeTestRule.onNodeWithTag("search_field").assertIsFocused()
```

## Testing State Changes

```kotlin
@Test
fun counter_incrementsOnClick() {
    composeTestRule.setContent {
        var count by remember { mutableIntStateOf(0) }
        Column {
            Text(text = "Count: $count", modifier = Modifier.testTag("count"))
            Button(onClick = { count++ }) {
                Text("Increment")
            }
        }
    }

    composeTestRule.onNodeWithTag("count").assertTextEquals("Count: 0")
    composeTestRule.onNodeWithText("Increment").performClick()
    composeTestRule.onNodeWithTag("count").assertTextEquals("Count: 1")
}

@Test
fun searchField_filtersResults() {
    composeTestRule.setContent {
        AppTheme {
            SearchScreen(viewModel = fakeViewModel)
        }
    }

    composeTestRule.onNodeWithTag("search_input").performTextInput("Istanbul")

    // Wait for async results
    composeTestRule.waitUntil(timeoutMillis = 5_000) {
        composeTestRule
            .onAllNodesWithTag("search_result")
            .fetchSemanticsNodes()
            .isNotEmpty()
    }

    composeTestRule
        .onAllNodesWithTag("search_result")
        .assertCountEquals(3)
}
```

## ViewModel Testing with Turbine

Turbine provides `test {}` extension on Flow for asserting emissions.

```kotlin
// build.gradle.kts
// testImplementation(libs.turbine)

class HomeViewModelTest {

    private val fakeRepository = FakeFlightRepository()
    private lateinit var viewModel: HomeViewModel

    @Before
    fun setup() {
        viewModel = HomeViewModel(
            getFlightsUseCase = GetFlightsUseCase(fakeRepository),
        )
    }

    @Test
    fun `loadFlights emits loading then success`() = runTest {
        fakeRepository.setFlights(listOf(testFlight))

        viewModel.uiState.test {
            // Initial state
            assertThat(awaitItem()).isEqualTo(HomeUiState.Loading)

            // Trigger load
            viewModel.loadFlights("IST", "JFK")

            // Success state
            val success = awaitItem()
            assertThat(success).isInstanceOf(HomeUiState.Success::class.java)
            assertThat((success as HomeUiState.Success).flights).hasSize(1)

            cancelAndIgnoreRemainingEvents()
        }
    }

    @Test
    fun `loadFlights emits error on failure`() = runTest {
        fakeRepository.setShouldFail(true)

        viewModel.uiState.test {
            skipItems(1) // skip Loading

            viewModel.loadFlights("IST", "JFK")

            val error = awaitItem()
            assertThat(error).isInstanceOf(HomeUiState.Error::class.java)

            cancelAndIgnoreRemainingEvents()
        }
    }
}
```

### Fake Repository

```kotlin
class FakeFlightRepository : FlightRepository {
    private val flights = MutableStateFlow<List<Flight>>(emptyList())
    private var shouldFail = false

    fun setFlights(list: List<Flight>) { flights.value = list }
    fun setShouldFail(fail: Boolean) { shouldFail = fail }

    override fun getFlights(origin: String, destination: String): Flow<List<Flight>> =
        if (shouldFail) flow { throw IOException("Network error") }
        else flights

    override fun searchFlights(query: String, date: LocalDate): Flow<List<Flight>> =
        flights.map { it.filter { f -> f.origin.code == query } }

    override suspend fun bookFlight(flightId: String): Result<Booking> =
        if (shouldFail) Result.failure(IOException("Booking failed"))
        else Result.success(Booking(id = "B1", confirmationCode = "CONF", status = BookingStatus.CONFIRMED))
}
```

## TestDispatcher and runTest

Use `runTest` with `StandardTestDispatcher` or `UnconfinedTestDispatcher` to
control coroutine execution in tests.

```kotlin
class FlightRepositoryTest {

    private val testDispatcher = StandardTestDispatcher()

    @Before
    fun setup() {
        Dispatchers.setMain(testDispatcher)
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
    }

    @Test
    fun `repository returns cached data`() = runTest {
        val repo = FlightRepositoryImpl(
            remoteDataSource = fakeRemote,
            localDataSource = fakeLocal,
            flightMapper = FlightMapper(),
        )

        val result = repo.getFlights("IST", "JFK").first()
        assertThat(result).hasSize(2)
    }
}
```

### TestDispatcher Comparison

| Dispatcher | Behavior | Use For |
|------------|----------|---------|
| `StandardTestDispatcher` | Pauses coroutines; advance manually with `advanceUntilIdle()` | Precise control over execution order |
| `UnconfinedTestDispatcher` | Runs coroutines eagerly | Simple tests where order does not matter |

```kotlin
@Test
fun `standard dispatcher requires manual advance`() = runTest(StandardTestDispatcher()) {
    var value = 0
    launch { value = 1 }
    assertThat(value).isEqualTo(0) // not yet executed
    advanceUntilIdle()
    assertThat(value).isEqualTo(1) // now executed
}

@Test
fun `unconfined dispatcher runs eagerly`() = runTest(UnconfinedTestDispatcher()) {
    var value = 0
    launch { value = 1 }
    assertThat(value).isEqualTo(1) // already executed
}
```

### Injectable DispatcherProvider for Tests

```kotlin
class TestDispatcherProvider(
    testDispatcher: TestDispatcher = StandardTestDispatcher(),
) : DispatcherProvider {
    override val main: CoroutineDispatcher = testDispatcher
    override val io: CoroutineDispatcher = testDispatcher
    override val default: CoroutineDispatcher = testDispatcher
}
```

## Screenshot Testing

### Roborazzi (JVM-based, no device required)

```kotlin
// build.gradle.kts
// testImplementation(libs.roborazzi)
// testImplementation(libs.roborazzi.compose)

@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34])
class FlightCardScreenshotTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @get:Rule
    val roborazziRule = RoborazziRule(
        options = RoborazziRule.Options(
            captureType = RoborazziRule.CaptureType.LastImage(),
        ),
    )

    @Test
    fun flightCard_default() {
        composeTestRule.setContent {
            AppTheme {
                FlightCard(flight = previewFlight)
            }
        }

        composeTestRule
            .onNodeWithTag("flight_card")
            .captureRoboImage()
    }

    @Test
    fun flightCard_dark() {
        composeTestRule.setContent {
            AppTheme(darkTheme = true) {
                FlightCard(flight = previewFlight)
            }
        }

        composeTestRule
            .onNodeWithTag("flight_card")
            .captureRoboImage()
    }
}
```

### Paparazzi (Layoutlib-based, no device required)

```kotlin
// build.gradle.kts
// testImplementation(libs.paparazzi)

class FlightCardPaparazziTest {

    @get:Rule
    val paparazzi = Paparazzi(
        deviceConfig = DeviceConfig.PIXEL_6,
        theme = "android:Theme.Material3.Light",
    )

    @Test
    fun flightCard_snapshot() {
        paparazzi.snapshot {
            AppTheme {
                FlightCard(flight = previewFlight)
            }
        }
    }
}
```

### Gradle Commands

```bash
# Roborazzi: record baselines
./gradlew recordRoborazziDebug

# Roborazzi: verify against baselines
./gradlew verifyRoborazziDebug

# Paparazzi: record baselines
./gradlew recordPaparazziDebug

# Paparazzi: verify against baselines
./gradlew verifyPaparazziDebug
```

## Do's and Don'ts

### Do's
- Use `Modifier.testTag()` on key composables for reliable node selection
- Use `waitUntil` for async UI updates instead of `Thread.sleep`
- Use Turbine `test {}` for StateFlow/Flow assertions in ViewModel tests
- Use `Dispatchers.setMain(testDispatcher)` in `@Before` and `resetMain()` in `@After`
- Use fake repositories/data sources instead of mocking frameworks for data layer tests
- Use `createComposeRule()` (no activity) for pure composable tests
- Screenshot test both light and dark themes

### Don'ts
- Do not use `Thread.sleep()` in Compose tests -- use `waitUntil` or `advanceUntilIdle()`
- Do not find nodes by implementation details (view IDs, class names)
- Do not test internal state directly -- test through the UI semantics
- Do not use `Mockito.mock()` on final Kotlin classes without mockito-inline
- Do not forget `cancelAndIgnoreRemainingEvents()` in Turbine tests
- Do not mix `StandardTestDispatcher` and `UnconfinedTestDispatcher` in the same test
- Do not call `setContent` more than once per test

## Troubleshooting

| Problem | Cause | Fix |
|---------|-------|-----|
| `ComposeNotIdleException` | Infinite animation or recomposition loop | Use `mainClock.autoAdvance = false` and advance manually |
| Node not found | Missing `testTag` or text not rendered yet | Add `testTag`, use `waitUntil`, or check lazy loading |
| `No compose hierarchies found` | `setContent` not called before assertions | Call `composeTestRule.setContent {}` first |
| Turbine `No value produced` timeout | StateFlow never emitted expected value | Check that the ViewModel action was triggered and dispatcher advanced |
| Roborazzi images differ on CI | Different JDK/platform rendering | Pin JDK version and use same SDK in CI config |
| Paparazzi `ClassNotFoundException` | Incompatible AGP version | Check Paparazzi compatibility matrix |
| Tests pass locally but fail on CI | Locale/timezone differences | Set locale and timezone in test setup or Gradle |
| `Dispatchers.Main` crash in unit test | Missing `setMain` | Add `Dispatchers.setMain(testDispatcher)` in `@Before` |

## Review Checklist

- [ ] Key composables have `Modifier.testTag()` for test discoverability
- [ ] No `Thread.sleep()` -- uses `waitUntil` or coroutine test APIs
- [ ] ViewModel tests use Turbine `test {}` for Flow assertions
- [ ] Tests set and reset `Dispatchers.Main` with TestDispatcher
- [ ] Fake implementations used for repositories and data sources
- [ ] Screenshot tests cover light and dark themes
- [ ] Screenshot baselines are committed to version control
- [ ] Each test method tests one behavior/scenario
- [ ] Test names follow `function_scenario_expected` convention
- [ ] Instrumented tests use `@HiltAndroidTest` with `HiltAndroidRule`
