Zum Inhalt springen

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

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

@Test
fun 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:

Terminal-Fenster
./gradlew :shared:testAndroidHostTest
./gradlew :androidApp:testDevDebugUnitTest --tests "*MyFeatureTest*"
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

ViewModels take AppDispatchers from com.appswithlove.kmp.coroutines; BaseViewModel builds catchingCoroutineScope from it.

class MyViewModel(
dispatchers: AppDispatchers,
) : BaseViewModel(dispatchers)
// In test
val dispatcher = UnconfinedTestDispatcher()
val vm = MyViewModel(dispatchers = AppDispatchers(default = dispatcher, io = dispatcher))

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().

@Test
fun state_updates() = runTest {
viewModel.state.test {
awaitItem() // Initial
viewModel.doAction()
awaitItem().data shouldBe expected
}
}
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 case
fake.saveResult = AppResult.Failure(AppError.Network)
@Test
fun addItem_showsInList() {
launchItemListScreen(composeTestRule) {
clickAddButton()
typeItemName("New Item")
clickSave()
} verify {
itemIsDisplayed("New Item")
}
}

methodName_condition_expectedResult

Examples:

  • login_withValidCredentials_returnsSuccess
  • state_whenLoaded_containsItems
  • deleteItem_onError_showsSnackbar
result shouldBe expected
list shouldContainExactly listOf(a, b, c)
list.shouldBeEmpty()
condition.shouldBeTrue()
result.shouldBeInstanceOf<Success>()
nullable.shouldNotBeNull()
flow.test {
awaitItem() // Get next emission
awaitItem().data shouldBe expected // Assert on emission
cancelAndIgnoreRemainingEvents() // Cleanup
}
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()
}
}
// BAD: Flow never emits
viewModel.state.value shouldBe expected
// GOOD: Use Turbine
viewModel.state.test {
awaitItem() shouldBe expected
}
// BAD: Missing Main dispatcher — flow never collects, test hangs indefinitely
composeTestRule.setContent { MyScreen(viewModel) }
// GOOD: Add MainDispatcherRule so collectAsStateWithLifecycle can dispatch
@get:Rule val mainDispatcherRule = MainDispatcherRule()
// BAD: Hardcoded dispatcher
private val scope = CoroutineScope(Dispatchers.Main)
// GOOD: Injected AppDispatchers, scope provided by BaseViewModel
class MyViewModel(
dispatchers: AppDispatchers,
) : BaseViewModel(dispatchers) {
fun load() = catchingCoroutineScope.launch { }
}
// WhileSubscribed needs active collector
viewModel.state.test {
// Now subscribed, flow starts
awaitItem()
}
Terminal-Fenster
# Check SDK version in test config
@Config(sdk = [33])
# Ensure correct test runner
./gradlew :androidApp:testDevDebugUnitTest
// BAD: Node may not exist yet
rule.onNodeWithTag("button").performClick()
// GOOD: Wait for idle
rule.waitForIdle()
rule.onNodeWithTag("button").performClick()
// BAD: Using runBlocking
@Test
fun test() = runBlocking { } // Use runTest
// BAD: Not using Turbine
viewModel.state.value shouldBe expected // Won't work with WhileSubscribed
// BAD: Mocks for everything
val mock = mockk<Repository>() // Prefer Fakes
// BAD: Navigation in every test
rule.onNodeWithTag("home").performClick()
// Use Robot composition instead
Terminal-Fenster
# 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 koverPrintCoverageCustom

Projects with other flavors swap Dev for their flavor name in the task.

  • All tests use UnconfinedTestDispatcher via AppDispatchers(default = it, io = it)
  • Repeated cases use a Burst parameterized test instead of copy-pasted tests
  • Compose UI tests include MainDispatcherRule (for collectAsStateWithLifecycle)
  • 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)

For advanced patterns and complete examples: