testing-android
| Type | Skill |
| Plugin | awl-testing · v0.0.27 |
| Invoke | /awl-testing:testing-android |
| Tools | Read, Write, Edit, Bash, Glob, Grep |
| Source | plugins/awl-testing/skills/testing-android/SKILL.md |
When Claude uses it
Section titled “When Claude uses it”Writes Kotlin unit tests using Kotest, Turbine for Flows, and Fakes. Writes Compose UI tests using Robolectric, Robot pattern, and Roborazzi screenshots. Use when “write kotlin tests”, “kotest test”, “turbine test”, “compose test”, “robolectric test”, “roborazzi screenshot”, “test viewmodel”, “test android”, “create fake”, “test flow”, “test stateflow”, “robot pattern”, “fix android test”, “debug kotlin test”.
Trigger phrases: write kotlin tests · kotest test · turbine test · compose test · robolectric test · roborazzi screenshot · test viewmodel · test android · create fake · test flow · test stateflow · robot pattern · fix android test · debug kotlin test
Definition
Section titled “Definition”Quick Start
Section titled “Quick Start”@Testfun login_validCredentials_returnsSuccess() = runTest { // Arrange val dispatcher = UnconfinedTestDispatcher() val viewModel = LoginViewModel( dispatchers = AppDispatchers(default = dispatcher, io = dispatcher), authHandler = FakeAuth(result = AppResult.Success(Unit)), )
// Act & Assert with Turbine viewModel.state.test { awaitItem() // Initial viewModel.login("user", "pass") awaitItem().isSuccess.shouldBeTrue() }}Run tests:
./gradlew :shared:testAndroidHostTest./gradlew :androidApp:testDevDebugUnitTest --tests "*MyFeatureTest*"When to Use
Section titled “When to Use”| Task | Approach |
|---|---|
| ViewModel/Repository | Unit test + Turbine |
| Same logic, many inputs | Burst parameterized test (see references/unit-testing.md) |
| Compose screen | Robolectric + Robot pattern |
| Visual regression | Roborazzi screenshots in a *ScreenshotTest class via captureScreenshot |
| Coroutines/Flows | UnconfinedTestDispatcher + Turbine |
Essential Rules
Section titled “Essential Rules”1. Always Inject Dispatchers
Section titled “1. Always Inject Dispatchers”ViewModels take AppDispatchers from com.appswithlove.kmp.coroutines; BaseViewModel builds catchingCoroutineScope from it.
class MyViewModel( dispatchers: AppDispatchers,) : BaseViewModel(dispatchers)
// In testval dispatcher = UnconfinedTestDispatcher()val vm = MyViewModel(dispatchers = AppDispatchers(default = dispatcher, io = dispatcher))2. Set Main Dispatcher in Compose UI Tests
Section titled “2. Set Main Dispatcher in Compose UI Tests”collectAsStateWithLifecycle uses Dispatchers.Main internally. Without setting it, the flow never collects and the test hangs indefinitely. Use a custom rule:
class MainDispatcherRule( val dispatcher: TestDispatcher = UnconfinedTestDispatcher(),) : TestWatcher() { override fun starting(description: Description) { Dispatchers.setMain(dispatcher) } override fun finished(description: Description) { Dispatchers.resetMain() }}Apply in every Compose UI test that uses collectAsStateWithLifecycle:
@RunWith(RobolectricTestRunner::class)class MyScreenTest { @get:Rule val mainDispatcherRule = MainDispatcherRule()
@get:Rule val composeTestRule = createComposeRule()
@Test fun screen_showsContent() { composeTestRule.setContent { // collectAsStateWithLifecycle now works MyScreen(viewModel = testViewModel) } // assertions... }}Recommend adding a custom rule in your project’s test utilities so every test class can simply add
@get:Rule val mainDispatcherRule = MainDispatcherRule().
3. Use Turbine for StateFlow
Section titled “3. Use Turbine for StateFlow”@Testfun state_updates() = runTest { viewModel.state.test { awaitItem() // Initial viewModel.doAction() awaitItem().data shouldBe expected }}4. Fakes Over Mocks
Section titled “4. Fakes Over Mocks”class UserProvidingFake : UserProviding, UserHandling { val users = MutableStateFlow<List<User>>(emptyList()) var saveResult: AppResult<Unit> = AppResult.Success(Unit)
override fun observeUsers(): Flow<List<User>> = users override suspend fun save(user: User): AppResult<Unit> = saveResult.onSuccess { users.update { it + user } }
fun reset() { users.value = emptyList(); saveResult = AppResult.Success(Unit) }}
// Failure casefake.saveResult = AppResult.Failure(AppError.Network)5. Robot Pattern for UI
Section titled “5. Robot Pattern for UI”@Testfun addItem_showsInList() { launchItemListScreen(composeTestRule) { clickAddButton() typeItemName("New Item") clickSave() } verify { itemIsDisplayed("New Item") }}Naming Convention
Section titled “Naming Convention”methodName_condition_expectedResult
Examples:
login_withValidCredentials_returnsSuccessstate_whenLoaded_containsItemsdeleteItem_onError_showsSnackbar
Common Assertions (Kotest)
Section titled “Common Assertions (Kotest)”result shouldBe expectedlist shouldContainExactly listOf(a, b, c)list.shouldBeEmpty()condition.shouldBeTrue()result.shouldBeInstanceOf<Success>()nullable.shouldNotBeNull()Turbine Essentials
Section titled “Turbine Essentials”flow.test { awaitItem() // Get next emission awaitItem().data shouldBe expected // Assert on emission cancelAndIgnoreRemainingEvents() // Cleanup}Robot Pattern
Section titled “Robot Pattern”fun launchMyScreen( rule: ComposeContentTestRule, block: MyRobot.() -> Unit,): MyRobot = MyRobot(rule).apply { rule.waitForIdle(); block() }
class MyRobot(private val rule: ComposeContentTestRule) { fun clickButton() = rule.onNodeWithTag("button").performClick() infix fun verify(block: Verification.() -> Unit) = Verification(rule).apply(block)
class Verification(private val rule: ComposeContentTestRule) { fun itemDisplayed(name: String) = rule.onNode(hasText(name)).assertIsDisplayed() }}Troubleshooting
Section titled “Troubleshooting”Test Hangs (No Emissions)
Section titled “Test Hangs (No Emissions)”// BAD: Flow never emitsviewModel.state.value shouldBe expected
// GOOD: Use TurbineviewModel.state.test { awaitItem() shouldBe expected}collectAsStateWithLifecycle Hangs Forever
Section titled “collectAsStateWithLifecycle Hangs Forever”// BAD: Missing Main dispatcher — flow never collects, test hangs indefinitelycomposeTestRule.setContent { MyScreen(viewModel) }
// GOOD: Add MainDispatcherRule so collectAsStateWithLifecycle can dispatch@get:Rule val mainDispatcherRule = MainDispatcherRule()Dispatcher Issues
Section titled “Dispatcher Issues”// BAD: Hardcoded dispatcherprivate val scope = CoroutineScope(Dispatchers.Main)
// GOOD: Injected AppDispatchers, scope provided by BaseViewModelclass MyViewModel( dispatchers: AppDispatchers,) : BaseViewModel(dispatchers) { fun load() = catchingCoroutineScope.launch { }}StateFlow WhileSubscribed Not Emitting
Section titled “StateFlow WhileSubscribed Not Emitting”// WhileSubscribed needs active collectorviewModel.state.test { // Now subscribed, flow starts awaitItem()}Robolectric Test Fails
Section titled “Robolectric Test Fails”# Check SDK version in test config@Config(sdk = [33])
# Ensure correct test runner./gradlew :androidApp:testDevDebugUnitTestCompose Node Not Found
Section titled “Compose Node Not Found”// BAD: Node may not exist yetrule.onNodeWithTag("button").performClick()
// GOOD: Wait for idlerule.waitForIdle()rule.onNodeWithTag("button").performClick()Anti-Patterns
Section titled “Anti-Patterns”// BAD: Using runBlocking@Testfun test() = runBlocking { } // Use runTest
// BAD: Not using TurbineviewModel.state.value shouldBe expected // Won't work with WhileSubscribed
// BAD: Mocks for everythingval mock = mockk<Repository>() // Prefer Fakes
// BAD: Navigation in every testrule.onNodeWithTag("home").performClick()// Use Robot composition insteadRunning Tests
Section titled “Running Tests”# Shared module (JVM host, includes commonTest)./gradlew :shared:testAndroidHostTest./gradlew :shared:testAndroidHostTest --tests "*MyClassTest*"./gradlew :shared:iosSimulatorArm64Test
# Android unit + Robolectric UI tests (dev flavor)./gradlew :androidApp:testDevDebugUnitTest./gradlew :androidApp:testDevDebugUnitTest --tests "*MyFeatureTest*"
# Screenshots; classes named *ScreenshotTest run only under a Roborazzi task./gradlew recordRoborazziDevDebug --tests "*MyFeatureScreenshotTest*"./gradlew verifyRoborazziDevDebug
# Coverage./gradlew koverPrintCoverageCustomProjects with other flavors swap Dev for their flavor name in the task.
PR Checklist
Section titled “PR Checklist”- All tests use
UnconfinedTestDispatcherviaAppDispatchers(default = it, io = it) - Repeated cases use a Burst parameterized test instead of copy-pasted tests
- Compose UI tests include
MainDispatcherRule(forcollectAsStateWithLifecycle) - Flows tested with Turbine
- Fakes reset in
@BeforeTest - UI tests use Robot pattern
- Test names follow
method_condition_result - No hardcoded strings (use test data objects)
References
Section titled “References”For advanced patterns and complete examples:
- unit-testing.md - Fakes, Kotest, Turbine, coroutines
- ui-testing.md - Robolectric, Robot pattern, Roborazzi
- troubleshooting.md - Common failures and solutions

