# Compose Testing Patterns

Full code examples for the Compose testing guide. Load this when you need a
complete, copyable example for a specific area: rule setup, node selection,
actions, assertions, state changes, ViewModel/Turbine tests, coroutine test
dispatchers, or screenshot tests. The top-level SKILL.md carries the guidance
and short snippets; this file carries the long examples.

## Contents

- [ComposeTestRule Setup](#composetestrule-setup)
- [Finding Nodes](#finding-nodes)
- [Test Tags from a Generated Registry](#test-tags-from-a-generated-registry)
- [Performing Actions](#performing-actions)
- [Assertions](#assertions)
- [Testing State Changes](#testing-state-changes)
- [ViewModel Test Base Class](#viewmodel-test-base-class)
- [ViewModel Testing with Turbine](#viewmodel-testing-with-turbine)
- [TestDispatcher and runTest](#testdispatcher-and-runtest)
- [Screenshot Testing](#screenshot-testing)
- [Compose Preview Screenshot Testing](#compose-preview-screenshot-testing)

## ComposeTestRule Setup

### Unit Tests (No Activity)

```kotlin
class ButtonComponentTest {

    @get:Rule
    val composeTestRule = createComposeRule()

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

        composeTestRule
            .onNodeWithText("Place Order")
            .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("Place Order")
composeTestRule.onNodeWithText("place order", ignoreCase = true)
composeTestRule.onNodeWithText("Order", substring = true)

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

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

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

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

// Parent/child traversal
composeTestRule
    .onNodeWithTag("order_card")
    .onChildren()
    .filterToOne(hasText("LDN"))
```

### Setting Test Tags

```kotlin
@Composable
fun OrderCard(order: Order, modifier: Modifier = Modifier) {
    Card(
        modifier = modifier.testTag("order_card_${order.id}"),
    ) {
        Text(
            text = order.store,
            modifier = Modifier.testTag("order_store"),
        )
    }
}
```

## Test Tags from a Generated Registry

String-literal tags drift: a rename in production silently orphans every test
that still passes the old string, and nothing fails to compile. Drive both the
`Modifier.testTag` in production and the `onNodeWithTag` in the test from one
generated constant. The registry is generated from the shared identifier source;
the codegen side lives in the `android-design-tokens-codegen` skill.

```kotlin
// Generated -- do not edit by hand
object TestTags {
    const val OrderCard = "order_card"
    const val OrderStore = "order_store"
    const val SearchField = "search_field"
}
```

```kotlin
// Production
Card(modifier = Modifier.testTag(TestTags.OrderCard)) { /* ... */ }

// Test -- same constant, so a rename is a compile error, not a silent miss
composeTestRule.onNodeWithTag(TestTags.OrderCard).assertIsDisplayed()
composeTestRule.onNodeWithTag(TestTags.SearchField).performTextInput("London")
```

## Performing Actions

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

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

// 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("order_list").performScrollToIndex(15)

// Scroll to key in LazyColumn
composeTestRule.onNodeWithTag("order_list").performScrollToKey("order-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("order_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("London")

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

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

## ViewModel Test Base Class

Centralize the coroutine main-dispatcher swap, teardown, and route/state
injection so every ViewModel test starts from the same clean setup. Use
`UnconfinedTestDispatcher()` as the main dispatcher (eager execution, no manual
`advanceUntilIdle()` for simple flows), reset it in teardown, and `unmockkAll()`
so MockK state never leaks between tests.

```kotlin
@OptIn(ExperimentalCoroutinesApi::class)
abstract class ViewModelTest {

    @Before
    fun setUpMainDispatcher() {
        Dispatchers.setMain(UnconfinedTestDispatcher())
    }

    @After
    fun tearDownMainDispatcher() {
        Dispatchers.resetMain()
        unmockkAll()
    }

    protected fun savedStateHandleOf(vararg args: Pair<String, Any?>): SavedStateHandle =
        SavedStateHandle(args.toMap())
}
```

For a type-safe `@Serializable` route, back the handle with the
`navigation-testing` artifact, whose `SavedStateHandle` builder decodes a route
object; the ViewModel then reads it via `savedStateHandle.toRoute<Route>()`.

```kotlin
class DetailViewModelTest : ViewModelTest() {

    @Test
    fun `loads item named in the route`() = runTest {
        val viewModel = DetailViewModel(
            savedStateHandle = savedStateHandleOf("itemId" to "42"),
            repository = FakeRepository(),
        )
        assertThat(viewModel.itemId).isEqualTo("42")
    }
}
```

### MockK inline mocking on modern JDKs

MockK's inline mocking (`mockkObject`, `mockkStatic`, mocking `final` classes)
loads a Byte Buddy agent at runtime. On JDK 21+ dynamic agent attachment is
restricted and self-attach fails with a MockK/Byte Buddy initialization error --
a common, hard-to-diagnose test failure. Attach the agent explicitly via
`-javaagent` instead of relying on self-attach.

```kotlin
// build.gradle.kts
val mockkAgent: Configuration by configurations.creating

dependencies {
    testImplementation(libs.mockk)
    mockkAgent(libs.bytebuddy.agent)
}

tasks.withType<Test>().configureEach {
    val agentJar = mockkAgent.singleFile
    jvmArgs("-javaagent:${agentJar.absolutePath}")
}
```

## 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 = FakeOrderRepository()
    private lateinit var viewModel: HomeViewModel

    @Before
    fun setup() {
        viewModel = HomeViewModel(
            getOrdersUseCase = GetOrdersUseCase(fakeRepository),
        )
    }

    @Test
    fun `loadOrders emits loading then success`() = runTest {
        fakeRepository.setOrders(listOf(testOrder))

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

            // Trigger load
            viewModel.loadOrders("LDN", "NYC")

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

            cancelAndIgnoreRemainingEvents()
        }
    }

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

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

            viewModel.loadOrders("LDN", "NYC")

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

            cancelAndIgnoreRemainingEvents()
        }
    }
}
```

### Fake Repository

```kotlin
class FakeOrderRepository : OrderRepository {
    private val orders = MutableStateFlow<List<Order>>(emptyList())
    private var shouldFail = false

    fun setOrders(list: List<Order>) { orders.value = list }
    fun setShouldFail(fail: Boolean) { shouldFail = fail }

    override fun getOrders(store: String, region: String): Flow<List<Order>> =
        if (shouldFail) flow { throw IOException("Network error") }
        else orders

    override fun searchOrders(query: String, date: LocalDate): Flow<List<Order>> =
        orders.map { it.filter { f -> f.store.code == query } }

    override suspend fun payOrder(orderId: String): Result<Payment> =
        if (shouldFail) Result.failure(IOException("Payment failed"))
        else Result.success(Payment(id = "P1", confirmationCode = "CONF", status = PaymentStatus.CONFIRMED))
}
```

## TestDispatcher and runTest

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

```kotlin
class OrderRepositoryTest {

    private val testDispatcher = StandardTestDispatcher()

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

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

    @Test
    fun `repository returns cached data`() = runTest {
        val repo = OrderRepositoryImpl(
            remoteDataSource = fakeRemote,
            localDataSource = fakeLocal,
            orderMapper = OrderMapper(),
        )

        val result = repo.getOrders("LDN", "NYC").first()
        assertThat(result).hasSize(2)
    }
}
```

### Dispatcher Behavior Examples

```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 OrderCardScreenshotTest {

    @get:Rule
    val composeTestRule = createComposeRule()

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

    @Test
    fun orderCard_default() {
        composeTestRule.setContent {
            AppTheme {
                OrderCard(order = previewOrder)
            }
        }

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

    @Test
    fun orderCard_dark() {
        composeTestRule.setContent {
            AppTheme(darkTheme = true) {
                OrderCard(order = previewOrder)
            }
        }

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

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

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

class OrderCardPaparazziTest {

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

    @Test
    fun orderCard_snapshot() {
        paparazzi.snapshot {
            AppTheme {
                OrderCard(order = previewOrder)
            }
        }
    }
}
```

### 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
```

## Compose Preview Screenshot Testing

AGP ships a first-party Compose Preview screenshot-testing plugin that renders
existing `@Preview` composables and diffs them against committed baselines,
alongside (not instead of) Paparazzi/Roborazzi. It is still alpha, so pin the
plugin version and expect API changes.

Enable it and set the image-difference threshold:

```properties
# gradle.properties
android.experimental.enableScreenshotTest=true
```

```kotlin
// build.gradle.kts
plugins {
    id("com.android.compose.screenshot")
}

android {
    testOptions {
        screenshotTests {
            imageDifferenceThreshold = 0.001f // fraction of pixels allowed to differ
        }
    }
}
```

Mark previews under the `screenshotTest` source set with `@PreviewTest`:

```kotlin
// src/screenshotTest/kotlin/...
class OrderCardScreenshots {

    @PreviewTest
    @Preview(showBackground = true)
    @Composable
    fun cardLight() {
        AppTheme { OrderCard(order = previewOrder) }
    }

    @PreviewTest
    @Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
    @Composable
    fun cardDark() {
        AppTheme(darkTheme = true) { OrderCard(order = previewOrder) }
    }
}
```

Reference images live per variant under
`src/<variant>ScreenshotTest/reference/` (for example
`src/freeDebugScreenshotTest/reference/`), so each flavor keeps its own
baselines. Record and validate:

```bash
# Record/update baselines for the variant
./gradlew updateDebugScreenshotTest

# Validate against committed baselines
./gradlew validateDebugScreenshotTest
```
