Wnb compose ui test
Skill wenubey/claude-android-skills/skills/wnb-compose-ui-test
Use this skill when writing, reviewing, or refactoring a Jetpack Compose UI test on Android. Enforces the canonical skeleton — @get:Rule createComposeRule(), an inline DispatcherProvider using Dispatchers.Unconfined so state propagates synchronously to the tree, a renderScreen(...) factory that constructs the ViewModel + sets content and returns the VM for assertion, semantics-first finders (onNodeWithText, onNodeWithTag, onNodeWithContentDescription, hasTestTag), Truth assertions on state, no Thread.sleep (waitForIdle / waitUntil instead), no hardcoded coordinates, no mocking of repositories (use fakes). Applies to instrumentation-style tests under app/src/androidTest. Triggers on "compose ui test", "createComposeRule", "androidTest", "instrumentation test", "onNodeWithText", "onNodeWithTag", "performClick", "performTextInput", "waitForIdle", "setContent", "compose test dispatcher", "test Screen composable".From its SKILL.md
npx -y skills add wenubey/claude-android-skills --skill wnb-compose-ui-testAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
8.5 KB, ~1.8k tokens by cl100k_base, as published. Nobody here has run it
Jetpack Compose UI test — canonical skeleton
Every screen-level Compose test in this project has the same shape. Same rule, same dispatcher trick, same factory pattern, same finder discipline. Do not invent variants.
Pairs with [[wnb-viewmodel-udf]] and [[wnb-viewmodel-test]] — a screen's ViewModel unit test verifies the state machine; this UI test verifies the state renders correctly and one critical interaction works.
Non-negotiables
@get:Rule val composeTestRule = createComposeRule()— one rule per test class. NevercreateAndroidComposeRule<Activity>()unless you specifically need the host Activity (rare).- Inline
DispatcherProviderwithDispatchers.Unconfinedfor all three (main,io,default). This makes VM state updates propagate to the tree synchronously — no missed emissions, noadvanceUntilIdle. Only exception: tests that specifically need to inspect intermediate loading state can use aStandardTestDispatcher+MainDispatcherRulecombo; the default is Unconfined. renderScreen(...)factory at the top of every test class. Constructs the VM with defaultable fake repositories, callscomposeTestRule.setContent { XxxScreen(viewModel = vm, …) }, and returns the VM so tests can assert againstvm.state.valuedirectly.- Semantics-first finders, in this preference order:
onNodeWithTag("submitButton")— best for interactive elements (addModifier.testTag(...)in the composable).onNodeWithContentDescription("Cart icon")— for icons/images; also earns you a11y coverage.onNodeWithText("Sign In")— for user-visible text. Fine for static labels; brittle for i18n strings and dynamic content.onNode(hasTestTag("...") and hasText("..."))— for compound matches.- Never coordinates (
onNodeAt(x, y)), never index-based sibling access.
- Assertion split. UI assertions on nodes:
assertIsDisplayed(),assertIsEnabled(),assertTextEquals(...),assertIsToggleable(). State assertions on the ViewModel via Truth:assertThat(vm.state.value.email).isEqualTo(...). composeTestRule.waitForIdle()after every interaction that triggers a coroutine or recomposition. NeverThread.sleep.- No mocking of repository interfaces. Use
TestXxxRepositoryfakes with in-memory state, mirroring the unit-test convention. - One test = one behavior. Render, one interaction (or none), one assertion cluster. If you need two flows, write two tests.
Canonical example
class FooScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
private val dispatcherProvider = object : DispatcherProvider {
override fun main(): CoroutineDispatcher = Dispatchers.Unconfined
override fun io(): CoroutineDispatcher = Dispatchers.Unconfined
override fun default(): CoroutineDispatcher = Dispatchers.Unconfined
}
private fun renderScreen(
repo: TestFooRepository = TestFooRepository(),
onNavigateToDetail: (String) -> Unit = {},
): FooViewModel {
val vm = FooViewModel(repo, dispatcherProvider)
composeTestRule.setContent {
FooScreen(
viewModel = vm,
onNavigateToDetail = onNavigateToDetail,
)
}
return vm
}
@Test
fun renders_title_and_empty_state_when_no_foos() {
renderScreen(repo = TestFooRepository(foos = emptyList()))
composeTestRule.onNodeWithText("Foos").assertIsDisplayed()
composeTestRule.onNodeWithText("No foos yet").assertIsDisplayed()
}
@Test
fun renders_list_of_foos_when_repo_returns_data() {
renderScreen(repo = TestFooRepository(foos = listOf(Foo("1", "Alpha"), Foo("2", "Beta"))))
composeTestRule.onNodeWithText("Alpha").assertIsDisplayed()
composeTestRule.onNodeWithText("Beta").assertIsDisplayed()
}
@Test
fun clicking_refresh_button_reloads_and_shows_new_items() {
val repo = TestFooRepository(foos = listOf(Foo("1", "Alpha")))
renderScreen(repo = repo)
composeTestRule.onNodeWithText("Alpha").assertIsDisplayed()
repo.setFoos(listOf(Foo("2", "Beta")))
composeTestRule.onNodeWithTag("refreshButton").performClick()
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("Beta").assertIsDisplayed()
}
@Test
fun clicking_a_row_invokes_navigation_callback_with_id() {
var navigatedTo: String? = null
renderScreen(
repo = TestFooRepository(foos = listOf(Foo("42", "Alpha"))),
onNavigateToDetail = { navigatedTo = it },
)
composeTestRule.onNodeWithText("Alpha").performClick()
composeTestRule.waitForIdle()
assertThat(navigatedTo).isEqualTo("42")
}
@Test
fun error_state_shows_retry_and_dismisses_on_click() {
val vm = renderScreen(repo = TestFooRepository(refreshResult = Result.failure(RuntimeException("nope"))))
vm.onAction(FooAction.Refresh)
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText("nope").assertIsDisplayed()
composeTestRule.onNodeWithTag("dismissErrorButton").performClick()
composeTestRule.waitForIdle()
assertThat(vm.state.value.error).isNull()
}
}
Common mistakes
Thread.sleep(500)to wait for recomposition → flaky on CI. UsecomposeTestRule.waitForIdle()orcomposeTestRule.waitUntil(2_000) { predicate }.- Using
Dispatchers.MaininDispatcherProvider→ hangs the test because there is no real main looper in the Compose test runner. UseDispatchers.Unconfined. onNodeWithText("Sign In")for a dynamic i18n string → breaks when copy changes. PreferonNodeWithTag("signInButton").- Hardcoded coordinates (
performTouchInput { down(Offset(200f, 400f)) }) → device-specific, brittle. Address by semantics. - Asserting only on the UI (
assertIsDisplayed) but never on VM state → misses regressions where the render is right but the state is wrong. Assert both when a behavior spans both. onNodeWithTexton merged text ("Email$errorSuffix") → won't match; text is merged from separateTextcomposables. UsehasText(..., substring = true)or a testTag.- Constructing the ViewModel in a
@Before/initblock → shared across tests, cross-test bleed. Always construct viarenderScreen(...)inside the test. - Mocking the repository interface → same reasoning as unit tests. Use a
TestFooRepositoryfake. setContent { }called twice in one test →IllegalStateException.renderScreenguarantees single call.- Reading
vm.state.valuebeforewaitForIdle()after an interaction → may read stale state. Always wait first. - Using
createAndroidComposeRule<MainActivity>()for a standalone screen → boots the whole app, slow, brittle. UsecreateComposeRule()unless the SUT explicitly reads from the hostActivity.
Preview screenshots vs UI tests
If the goal is "does this screen render for every state" — that's a screenshot / preview job (Paparazzi, Roborazzi, or the on-device HotSwan preview catalog), not a Compose UI test. Reserve Compose UI tests for behavior (state + interaction), not visual regression.
Related skills
[[wnb-viewmodel-udf]]— the ViewModel shape the SUT depends on.[[wnb-viewmodel-test]]— the unit-test companion. Split what each covers: unit test = state machine; UI test = renders + one critical interaction.[[wnb-compose-animation]]— animation authoring rules; testing animated UI has extra care aroundmainClock.advanceTimeBy.
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.