compose-tests

$npx mdskill add stripe/stripe-android/compose-tests

Patterns for testing Jetpack Compose UI in the Stripe Android SDK.

SKILL.md

.github/skills/compose-testsView on GitHub ↗
---
name: compose-tests
description: Use when writing Compose UI tests in stripe-android — covers composeRule setup, Robolectric annotations, node assertions, and test tag patterns
---

# Compose UI Tests

Patterns for testing Jetpack Compose UI in the Stripe Android SDK.

## Test Class Setup

Every Compose test needs three rules and Robolectric:

```kotlin
@RunWith(RobolectricTestRunner::class)
internal class MyComposableTest {
    @get:Rule
    val composeRule = createComposeRule()

    @get:Rule
    val composeCleanupRule = createComposeCleanupRule()

    @get:Rule
    val coroutineTestRule = CoroutineTestRule(UnconfinedTestDispatcher())
}
```

- `createComposeRule()` — provides the Compose test harness
- `createComposeCleanupRule()` — workaround for Robolectric resource leak (import from `com.stripe.android.testing`)
- `CoroutineTestRule` — controls coroutine dispatching in tests

## runScenario for Compose Tests

Use the same `runScenario` pattern from `write-unit-tests`, wrapping `composeRule.setContent`:

```kotlin
@Test
fun `required fields are visible`() = runScenario {
    page.country.assertIsDisplayed()
    page.zipCode.assertIsDisplayed()
    page.line1.assertDoesNotExist()
}

@Test
fun `input changes update state`() = runScenario(
    addressCollectionMode = AddressCollectionMode.Full,
) {
    page.zipCode.performTextReplacement("94100")
    assertThat(formState?.postalCode?.value).isEqualTo("94100")
}

private fun runScenario(
    addressCollectionMode: AddressCollectionMode = AddressCollectionMode.Automatic,
    block: TestScenario.() -> Unit,
) {
    composeRule.setContent {
        MyComposableUI(addressCollectionMode = addressCollectionMode)
    }
    block(TestScenario(MyPage(composeRule)))
}

private data class TestScenario(val page: MyPage)
```

## Finding Nodes

| Finder | Use when |
|--------|----------|
| `onNodeWithTag(TAG)` | Element has a test tag (preferred) |
| `onNodeWithContentDescription("desc")` | Testing accessibility only — prefer test tags otherwise |
| `onAllNodesWithTag(TAG)` | Multiple elements share a tag |
| `onNode(hasTestTag(TAG).and(hasText("x")))` | Combining matchers |

## Assertions

| Assertion | Purpose |
|-----------|---------|
| `assertIsDisplayed()` | Element is visible |
| `assertExists()` | Element exists (may not be visible) |
| `assertDoesNotExist()` | Element not in hierarchy |
| `assertIsNotDisplayed()` | Exists but not visible |
| `assertIsEnabled()` / `assertIsNotEnabled()` | Enabled state |
| `assertTextContains("text")` | Text field contains value |
| `assertContentDescriptionContains("desc")` | Accessibility description |
| `assert(hasText("text"))` | General matcher assertion |

## Actions

| Action | Purpose |
|--------|---------|
| `performClick()` | Tap element |
| `performTextReplacement("text")` | Set text field value |
| `performScrollTo()` | Scroll element into view |

## Test Tags

Define tags as constants in production code, use `Modifier.testTag()`:

```kotlin
// In production code:
internal const val SAVE_BUTTON_TEST_TAG = "save_button"

Box(modifier = Modifier.testTag(SAVE_BUTTON_TEST_TAG)) { ... }

// In test code:
composeRule.onNodeWithTag(SAVE_BUTTON_TEST_TAG).assertIsDisplayed()
```

## Waiting for Recomposition


```kotlin
// Wait for pending recompositions and then for async content
composeRule.waitUntilWithIdle(
    conditionDescription = "node with tag $MY_TAG to appear",
) {
    composeRule.onAllNodesWithTag(MY_TAG)
        .fetchSemanticsNodes(atLeastOneRootRequired = false)
        .isNotEmpty()
}
```

## Common Mistakes

- **Missing `composeCleanupRule`** — causes resource leaks with Robolectric, flaky tests
- **Missing `@RunWith(RobolectricTestRunner::class)`** — Compose tests need Android framework
- **Using `onNodeWithText` for dynamic content** — prefer test tags for stability
- **Not calling `waitForIdle()` after state changes** — assertions may run before recomposition
- **Calling `waitUntil` directly for async UI** — use `waitUntilWithIdle` for the standard idle
  synchronization and 5,000 ms timeout
- **Requiring a Compose root while polling** — pass `atLeastOneRootRequired = false` to
  `fetchSemanticsNodes`

More from stripe/stripe-android

SkillDescription
delegate-low-reasoning-workUse when running routine Gradle tests, builds, lint, formatting, generated-output checks, or verbose output collection in stripe-android. Delegate independent low-judgment work to a low-cost subagent to preserve the main context.
fixing-flakesReproduce, diagnose, and fix flaky stripe-android tests with focused, repeated validation and evidence-driven synchronization or determinism fixes. Use for intermittent unit, coroutine, Robolectric, Compose, screenshot, instrumentation, Gradle Managed Device, PaymentSheet E2E, Chrome, WebView, UiAutomator, or Espresso failures, while ensuring temporary ShampooRule edits never enter commits or pull requests.
network-testsUse when writing NetworkRule integration tests in stripe-android — covers testBodyFromFile, inline JSON modification, request matchers, and fixture patterns
parameterized-testsUse when writing, converting, or reviewing parameterized JVM, Robolectric, instrumentation, or Paparazzi tests in stripe-android. Covers TestParameterInjector runners, source-set dependencies, method versus class parameters, value providers, case matrices, and stable test names.
screenshot-testsUse when writing or running Paparazzi screenshot tests in stripe-android — covers PaparazziRule setup, recording/verifying commands, and test structure
write-unit-testsUse when writing or structuring unit tests or fake test implementations in stripe-android — covers scenarios, Turbine interaction tracking and Flow testing, and Truth assertions