协作头脑风暴
brainstorming
在实现前澄清需求、探索设计选择,并形成清晰的开发方向。
structuring-a-compose-test【开发与工程】Use this skill to structure a Jetpack Compose UI test class the way androidx itself writes them — `@MediumTest` + `@RunWith(AndroidJUnit4::class)`, a single `createComposeRule(StandardTestDispatcher())` rule, hoisted `mutableStateOf` declared above `setContent { }`, and the Test → Find → Assert → Act → Re-assert flow. Covers when to use `createAndroi…
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
name: structuring-a-compose-test
description: Use this skill to structure a Jetpack Compose UI test class the way androidx itself writes them — @MediumTest + @RunWith(AndroidJUnit4::class), a single createComposeRule(StandardTestDispatcher()) rule, hoisted mutableStateOf declared above setContent { }, and the Test → Find → Assert → Act → Re-assert flow. Covers when to use createAndroidComposeRule<MyActivity>() for custom Activities, how to drive state from outside the composition via runOnIdle { }, why state must NOT be hoisted inside setContent, and why @get:Rule createComposeRule() and runComposeUiTest { } MUST NOT both appear in the same test. Use when the developer asks "how do I write a Compose test", "where do I declare state", "how do I name the test class", "MediumTest vs LargeTest", "rule.setContent inside @Before", or shows a test class that won't compile or doesn't drive state from outside.
license: Apache-2.0. See LICENSE for complete terms.
metadata:
author: Jaewoong Eum (skydoves)
keywords:
Compose tests fail or rot because their structure is wrong, not because the assertions are wrong. State that lives inside setContent { } cannot be driven from the test thread; setContent called inside @Before becomes a hidden race; combining @get:Rule with runComposeUiTest { } creates two competing test environments. This skill encodes the exact class skeleton used by androidx.compose.material3 and androidx.compose.foundation so the developer's tests behave the same way.
*Test.kt and asks where to put state, the rule, and setContent.setContent into @Before and the test is now flaky or will not start.setContent { }.@get:Rule createComposeRule() and runComposeUiTest { } and gets confusing failures.runComposeUiTest); use ../../setup/choosing-test-rule-vs-runtest/SKILL.md.createComposeRule; use ../../setup/configuring-test-dependencies/SKILL.md.../../setup/setting-up-host-vs-device-tests/SKILL.md.LazyColumn/LazyRow; layer ../testing-lazy-lists/SKILL.md on top of this skeleton.androidx.compose.ui:ui-test-junit4 on androidTestImplementation, androidx.compose.ui:ui-test-manifest on debugImplementation. See ../../setup/configuring-test-dependencies/SKILL.md.androidx.activity.ComponentActivity (provided by ui-test-manifest). For a custom Activity the androidTest source set MUST declare it in AndroidManifest.xml.src/androidTest/kotlin/... (instrumentation) or src/test/kotlin/... (Robolectric host). 1. Mirror the production package. Place MyButtonTest.kt in the same package as MyButton.kt. The class name is <ProductionFile>Test (SwitchTest.kt, LazyListTest.kt).
2. Annotate the class. @MediumTest is the default budget. Use @LargeTest only for tests that intentionally take seconds (long-clicks via performTouchInput { longClick() }, multi-second animations). @RunWith(AndroidJUnit4::class) is non-negotiable for instrumentation.
3. Declare exactly one rule. Prefer the v2 entry point — it defaults the composition dispatcher to StandardTestDispatcher, matching kotlinx.coroutines.test.runTest.
import androidx.compose.ui.test.junit4.v2.createComposeRule
import kotlinx.coroutines.test.StandardTestDispatcher
@MediumTest
@RunWith(AndroidJUnit4::class)
class MyButtonTest {
@get:Rule val rule = createComposeRule(StandardTestDispatcher())
}
For a custom Activity (e.g. FragmentActivity, a screen-under-test Activity), use the reified v2 helper:
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
@get:Rule val rule = createAndroidComposeRule<FragmentActivity>(StandardTestDispatcher())
Cited from compose/foundation/foundation/src/androidDeviceTest/kotlin/androidx/compose/foundation/textfield/TextFieldFocusCustomDialogTest.kt:60.
4. Hoist state ABOVE setContent. Anything the test wants to mutate or read must be a property declared in the test method, then captured by reference inside the composable.
5. Call setContent { } INSIDE the @Test method, not in @Before. Each test owns its own composition. Tests with shared setContent in @Before cannot vary content between cases and lose the per-test state setup the framework expects.
6. Follow Test → Find → Assert → Act → Re-assert. The canonical shape from compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/SwitchTest.kt:240-262 (the switch_stateChange_movesThumb test):
@Test
fun switch_stateChange_movesThumb() {
var checked by mutableStateOf(false) // STATE (hoisted)
rule.setMaterialContent(lightColorScheme()) { // SET CONTENT (in test body)
val spacer = @Composable { Spacer(Modifier.size(16.dp).testTag("spacer")) }
Switch(
modifier = Modifier.testTag(defaultSwitchTag),
checked = checked,
thumbContent = spacer,
onCheckedChange = { checked = it },
)
}
rule.onNodeWithTag("spacer", useUnmergedTree = true) // FIND + ASSERT
.assertLeftPositionInRootIsEqualTo(8.dp)
rule.runOnIdle { checked = true } // ACT (mutate from outside)
rule.onNodeWithTag("spacer", useUnmergedTree = true) // RE-ASSERT
.assertLeftPositionInRootIsEqualTo(28.dp)
rule.runOnIdle { checked = false }
rule.onNodeWithTag("spacer", useUnmergedTree = true).assertLeftPositionInRootIsEqualTo(8.dp)
}
rule.runOnIdle { … }. The block runs after waitForIdle(), so observed values reflect the latest composition.val (index, offset) = rule.runOnIdle {
state.firstVisibleItemIndex to state.firstVisibleItemScrollOffset
}
Cited from compose/foundation/foundation/integration-tests/lazy-tests/src/androidTest/kotlin/androidx/compose/foundation/lazy/list/LazyListTest.kt:903-904.
rule.runOnIdle { … }. Direct assignment from the test thread races the recomposer.rule.runOnIdle { count = 5 }
setContent// WRONG
@Test
fun toggle() {
rule.setContent {
var checked by remember { mutableStateOf(false) } // <-- trapped inside composition
Switch(checked = checked, onCheckedChange = { checked = it })
}
rule.runOnIdle { checked = true } // does not compile / has no reference
}
// WRONG because: the test thread has no reference to `checked`. The state cannot be driven
// from outside, and the test can only verify the very first frame.
// RIGHT
@Test
fun toggle() {
var checked by mutableStateOf(false) // <-- hoisted to test scope
rule.setContent {
Switch(checked = checked, onCheckedChange = { checked = it })
}
rule.onNodeWithTag("switch").assertIsOff()
rule.runOnIdle { checked = true } // mutate via runOnIdle
rule.onNodeWithTag("switch").assertIsOn()
}
setContent in @Before// WRONG
@Before
fun setUp() {
rule.setContent { MyScreen(state) } // <-- shared across all tests
}
@Test fun stateA() { /* mutate `state` here, but it was already composed without this case */ }
// RIGHT
@Test
fun stateA() {
val state = ScreenState(initial = "A")
rule.setContent { MyScreen(state) } // <-- per-test composition
rule.onNodeWithTag("title").assertTextEquals("A")
}
runComposeUiTest// WRONG
class MyTest {
@get:Rule val rule = createComposeRule(StandardTestDispatcher())
@Test fun a() = runComposeUiTest { // <-- second test environment
setContent { /* … */ }
}
}
// WRONG because: createComposeRule and runComposeUiTest each manage an independent test
// environment (composition + MainTestClock + IdlingResourceRegistry). Mixing them produces
// undefined behavior. KDoc warns explicitly: see the ComposeUiTest.android.kt KDoc on
// runComposeUiTest, runAndroidComposeUiTest, runEmptyComposeUiTest, and the v2 variants.
// RIGHT — pick exactly one entry point per class
class MyRuleTest {
@get:Rule val rule = createComposeRule(StandardTestDispatcher())
@Test fun a() { rule.setContent { /* … */ } }
}
// or
class MyFunctionTest {
@Test fun a() = runComposeUiTest { setContent { /* … */ } }
}
// RIGHT — ComponentActivity comes free from ui-test-manifest
@get:Rule val rule = createComposeRule(StandardTestDispatcher())
// RIGHT — a custom Activity must be declared in src/androidTest/AndroidManifest.xml
@get:Rule val rule = createAndroidComposeRule<MyActivity>(StandardTestDispatcher())
<!-- src/androidTest/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity android:name=".MyActivity" />
</application>
</manifest>
MediumTest vs LargeTest@MediumTest // default — sub-second tests
class SwitchTest { /* … */ }
@LargeTest // long-clicks, multi-second animations
class CombinedClickableTest { /* … */ }
@MediumTest is used by SwitchTest.kt:76 and most material3/foundation suites. @LargeTest is reserved for tests whose total wall time intentionally exceeds the medium budget.
@RunWith(AndroidJUnit4::class) and a size annotation (@MediumTest by default).createComposeRule(...), createAndroidComposeRule<A>(...), or createEmptyComposeRule(). MUST NOT combine a @get:Rule rule with runComposeUiTest { } in the same class. KDoc cited at compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/ComposeUiTest.android.kt:158, 204, 247, 338.androidx.compose.ui.test.junit4.v2.createComposeRule / createAndroidComposeRule. The v1 forms are @Deprecated(level = WARNING) because they use UnconfinedTestDispatcher instead of StandardTestDispatcher (skydoves directive #6).var x by mutableStateOf(...) above setContent { } whenever the test must drive or observe it.rule.setContent { } inside the @Test method. MUST NOT call it in @Before.rule.runOnIdle { state = … } (or rule.runOnUiThread { … } for cases where idling first is undesirable, e.g. mainClock.autoAdvance = false). See skydoves directive #5. Detail in ../../synchronization/synchronizing-with-idle/SKILL.md.rule.runOnIdle { state.value }. Direct reads from the test thread snapshot the wrong frame.Modifier.testTag(...) declared as a const in production (skydoves directive #1). Text finders are i18n-fragile. See ../../finders/finding-nodes-by-tag-text-content/SKILL.md.SwitchTest.kt:140-147).@RunWith(AndroidJUnit4::class) + @MediumTest (or @LargeTest).@get:Rule declared and it is a createComposeRule(...) / createAndroidComposeRule(...) / createEmptyComposeRule() instance.runComposeUiTest { } invocation appears in the same class.setContent { } appears inside @Test methods only — not in @Before / @BeforeEach.var x by mutableStateOf(...) above setContent, captured by reference inside the composable.setContent go through rule.runOnIdle { } or rule.runOnUiThread { }.rule.runOnIdle { }.Thread.sleep appears in the test method (skydoves directive #7). See ../../synchronization/synchronizing-with-idle/SKILL.md.src/androidTest/AndroidManifest.xml (not just the production AndroidManifest.xml).compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/SwitchTest.kt:76-263compose/foundation/foundation/src/androidDeviceTest/kotlin/androidx/compose/foundation/textfield/TextFieldFocusCustomDialogTest.kt:57-60compose/ui/ui-test-junit4/src/androidMain/kotlin/androidx/compose/ui/test/junit4/v2/AndroidComposeTestRule.android.ktcompose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/ComposeUiTest.android.kt:158, 204, 247, 338runOnIdle semantics: compose/ui/ui-test-junit4/src/jvmAndAndroidMain/kotlin/androidx/compose/ui/test/junit4/ComposeTestRule.jvmAndAndroid.kt