---
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."
metadata:
  source: multi-agent-pipeline
---

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

Full, copyable code for every section below lives in
[references/patterns.md](references/patterns.md). Load it when you need a
complete example; this guide keeps the decisions and short snippets.

## 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)
- [Do's and Don'ts](#dos-and-donts)
- [Troubleshooting](#troubleshooting)
- [Review Checklist](#review-checklist)

## ComposeTestRule Setup

Use `createComposeRule()` for pure composable tests with no activity. Use
`createAndroidComposeRule<MainActivity>()` for instrumented tests, and pair it
with `HiltAndroidRule` (ordered rules, `hiltRule.inject()` in `@Before`) when
the screen needs injected dependencies.

```kotlin
@get:Rule
val composeTestRule = createComposeRule()
// composeTestRule.setContent { AppTheme { PrimaryButton(label = "Book", onClick = {}) } }
```

Full unit and instrumented (Hilt) setups: references/patterns.md#composetestrule-setup

## Finding Nodes

Select nodes by semantics, never by implementation details. Set
`Modifier.testTag("tag")` on key composables so tests find them reliably.

```kotlin
composeTestRule.onNodeWithText("Place Order")
composeTestRule.onNodeWithTag("order_list")
composeTestRule.onNodeWithContentDescription("Search orders")
```

Combined matchers (`hasText and hasClickAction`), `onAllNodesWith*`, parent/child
traversal, and tag placement: references/patterns.md#finding-nodes

## Test Tags from a Generated Registry

Do not scatter tag strings as literals. A production rename then silently orphans
every test that still passes the old string, and nothing fails to compile. Drive
the `Modifier.testTag` and the matching `onNodeWithTag` from one generated
constant so a rename is a compile error. The registry is generated from the
shared identifier source; the codegen side lives in the
`android-design-tokens-codegen` skill.

```kotlin
Modifier.testTag(TestTags.OrderCard)
composeTestRule.onNodeWithTag(TestTags.OrderCard).assertIsDisplayed()
```

Keep the registry predictable: keys follow one hierarchy (`Components.<Name>.<Element>`
in the source, generated as constants), a fixed element vocabulary (`Container` on
every component root, then `Title`, `Subtitle`, `LeadingIcon`, `TrailingIcon`,
`Action`, `CloseButton`, `Item`), and an indexed format such as `..._item_%s` for
repeated children. The tool that adds entries rejects a duplicate key or value, so
two components can never share a tag.

Full generated-registry and usage example: references/patterns.md#test-tags-from-a-generated-registry

## Performing Actions

Actions run on a resolved node: `performClick()`, `performTextInput(text)`,
`performTextClearance()` / `performTextReplacement(text)`, `performScrollTo()`,
`performScrollToIndex(i)` / `performScrollToKey(k)`, and gestures via
`performTouchInput { swipeLeft(); longClick() }`.

Full action reference: references/patterns.md#performing-actions

## Assertions

Assert through the UI semantics, not internal state:
`assertIsDisplayed()` / `assertExists()` / `assertDoesNotExist()`,
`assertTextEquals(...)` / `assertTextContains(...)`,
`assertIsEnabled()`, `assertIsSelected()`, `assertIsOn()`,
`assertCountEquals(n)`, `assertIsFocused()`.

Full assertion reference: references/patterns.md#assertions

## Testing State Changes

Drive the UI, then assert the visible result. For async updates use
`waitUntil(timeoutMillis) { ... }` on fetched semantics nodes instead of
`Thread.sleep`.

Full counter and async-filter examples: references/patterns.md#testing-state-changes

## ViewModel Test Base Class

Centralize the coroutine setup in one base class every ViewModel test extends:
`Dispatchers.setMain(UnconfinedTestDispatcher())` in `@Before`, and
`Dispatchers.resetMain()` plus `unmockkAll()` in `@After`. Add a
`SavedStateHandle` helper so tests inject route arguments the same way. For a
type-safe `@Serializable` route, back the handle with the `navigation-testing`
artifact and read it via `savedStateHandle.toRoute<Route>()`.

MockK inline mocking (`mockkObject`, `mockkStatic`, mocking `final` classes)
loads a Byte Buddy agent at runtime. On JDK 21+ dynamic agent attachment is
restricted, so self-attach fails with an opaque initialization error. Attach the
agent explicitly with a `-javaagent` JVM arg on the `Test` task instead of
relying on self-attach.

Full base class, route injection, and the `-javaagent` Gradle wiring:
references/patterns.md#viewmodel-test-base-class

## ViewModel Testing with Turbine

Turbine's `test {}` extension on a `Flow`/`StateFlow` asserts emissions one at a
time (`awaitItem()`, `skipItems(n)`, `cancelAndIgnoreRemainingEvents()`). Back
the ViewModel with a fake repository rather than a mocking framework.

```kotlin
viewModel.uiState.test {
    assertThat(awaitItem()).isEqualTo(HomeUiState.Loading)
    viewModel.loadOrders("LDN", "NYC")
    assertThat(awaitItem()).isInstanceOf(HomeUiState.Success::class.java)
    cancelAndIgnoreRemainingEvents()
}
```

Full ViewModel test and fake repository: references/patterns.md#viewmodel-testing-with-turbine

## TestDispatcher and runTest

Run coroutine tests inside `runTest`. Set `Dispatchers.setMain(testDispatcher)`
in `@Before` and `Dispatchers.resetMain()` in `@After`. Pick one dispatcher per
test; do not mix them.

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

Dispatcher behavior examples and an injectable `TestDispatcherProvider`:
references/patterns.md#testdispatcher-and-runtest

## Screenshot Testing

Both frameworks render on the JVM with no device. Roborazzi uses Robolectric and
captures via `captureRoboImage()`; Paparazzi uses Layoutlib and captures via
`paparazzi.snapshot { ... }`. Screenshot both light and dark themes and commit
the baselines.

```bash
./gradlew recordRoborazziDebug   # or recordPaparazziDebug
./gradlew verifyRoborazziDebug   # or verifyPaparazziDebug
```

Full Roborazzi and Paparazzi test classes: references/patterns.md#screenshot-testing

## Compose Preview Screenshot Testing

AGP's first-party Compose Preview screenshot-testing plugin
(`com.android.compose.screenshot`) renders existing `@Preview` composables and
diffs them against committed baselines, alongside Paparazzi/Roborazzi rather than
replacing them. It is still alpha -- pin the plugin version and expect API
changes. Enable it with `android.experimental.enableScreenshotTest=true`, mark
previews with `@PreviewTest` under the `screenshotTest` source set, set
`imageDifferenceThreshold` in the `screenshotTests` DSL, and keep per-flavor
baselines under `src/<variant>ScreenshotTest/reference/`.

```bash
./gradlew updateDebugScreenshotTest    # record/update baselines
./gradlew validateDebugScreenshotTest  # validate against baselines
```

Full plugin config, `@PreviewTest` previews, and threshold DSL:
references/patterns.md#compose-preview-screenshot-testing

## Do's and Don'ts

### Do's
- Drive `testTag`/`onNodeWithTag` from a generated registry, not string literals
- Use `waitUntil` for async UI updates instead of `Thread.sleep`
- Use Turbine `test {}` for StateFlow/Flow assertions in ViewModel tests
- Extend one base class that swaps the main dispatcher and calls `resetMain()` + `unmockkAll()`
- Attach the Byte Buddy agent via `-javaagent` when MockK does inline mocking on JDK 21+
- 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 hardcode `testTag` strings as literals in both production and tests
- 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` |
| MockK inline mock init fails on JDK 21+ | Byte Buddy self-attach restricted | Attach the agent via `-javaagent` on the `Test` task |
| `@PreviewTest` previews not collected | `screenshotTest` source set or flag missing | Set `enableScreenshotTest=true`, place previews under `src/screenshotTest` |

## Review Checklist

- [ ] Key composables have `Modifier.testTag()` from a generated registry, not literals
- [ ] No `Thread.sleep()` -- uses `waitUntil` or coroutine test APIs
- [ ] ViewModel tests use Turbine `test {}` for Flow assertions
- [ ] ViewModel tests extend a base class that swaps/resets `Dispatchers.Main` and calls `unmockkAll()`
- [ ] MockK inline mocking has the `-javaagent` Byte Buddy wiring for JDK 21+
- [ ] Fake implementations used for repositories and data sources
- [ ] Screenshot tests cover light and dark themes (Paparazzi/Roborazzi and/or AGP `@PreviewTest`)
- [ ] 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`
