plugins/android-skills/skills/android-tdd/SKILL.md
Use when writing, fixing, or refactoring Android/KMP code in Kotlin — supplements superpowers:test-driven-development with Android's three-tier test model, fake-first strategy, coroutine testing, and Compose UI testing.
npx skillsauth add rcosteira79/android-skills android-tddInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Extends superpowers:test-driven-development with Android-specific patterns.
REQUIRED BACKGROUND: You MUST follow superpowers:test-driven-development. This skill adds Android context — it does not replace the Iron Law or RED-GREEN-REFACTOR cycle.
Choose the lowest tier that can meaningfully test the behaviour:
| Tier | Location | Runs on | Speed | Use for |
|------|----------|---------|-------|---------|
| Unit | src/test/ | JVM | Fast | Pure logic, ViewModels, UseCases, Repositories |
| Integration | src/test/ with Robolectric | JVM (simulated) | Medium | Room DAOs, Context-dependent code, Fragment logic |
| Instrumented | src/androidTest/ | Device/emulator | Slow | Compose UI, real DB, end-to-end flows |
Rationalization trap: "Instrumented tests are too slow" is never a reason to skip UI testing for UI code.
Prefer hand-written fakes to mocking frameworks.
Fakes implement the real interface with in-memory behaviour. They are:
Use a mock only when:
// PREFER: Fake with real interface
class FakeUserRepository : UserRepository {
private val users = mutableMapOf<String, User>()
var saveCallCount: Int = 0
private set
override suspend fun findById(id: String): User? = users[id]
override suspend fun save(user: User) {
saveCallCount++
users[user.id] = user
}
}
// AVOID: Mock with call verification
val mockRepo = mockk<UserRepository>()
every { mockRepo.save(any()) } just Runs
verify { mockRepo.save(expectedUser) } // tests implementation, not behaviour
Match the project's existing assertion library. If there is no established convention, ask the user which they prefer. Common options: kotlin-test (assertEquals, assertIs), Google Truth (assertThat(...).isEqualTo(...)), Kotest matchers (shouldBe, shouldBeInstanceOf). The examples in this skill use kotlin-test style — adapt to the project's choice.
Use runTest for all suspend functions. Never use runBlocking in tests.
@Test
fun `given invalid credentials, when logging in, then emits error state`() = runTest {
// Given
val fakeRepository = FakeAuthRepository()
val viewModel = LoginViewModel(fakeRepository)
// When
viewModel.login(inputEmail = "[email protected]", inputPassword = "wrong")
advanceUntilIdle()
// Then
val actualState = viewModel.uiState.value
assertIs<LoginUiState.Error>(actualState)
}
Hot flows (StateFlow / SharedFlow) never complete, so collecting one directly on the TestScope hangs to the 60-second runTest timeout:
viewModel.uiState.collect { seen += it } // WRONG: hangs the test
Use Turbine's test {}, which collects and cancels for you:
viewModel.uiState.test {
assertIs<LoginUiState.Loading>(awaitItem())
assertIs<LoginUiState.Error>(awaitItem())
cancelAndIgnoreRemainingEvents()
}
Or, without Turbine, launch the collector on backgroundScope (auto-cancelled at end of test):
viewModel.uiState.onEach { seen += it }.launchIn(backgroundScope)
Dispatcher injection is required for testability:
class LoginViewModel(
private val repository: AuthRepository,
private val dispatcher: CoroutineDispatcher = Dispatchers.Main
) : ViewModel()
Replace Dispatchers.Main in tests via the canonical MainDispatcherRule from androidx testutils-ktx:
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
val dispatcher: TestDispatcher = StandardTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description?) {
super.starting(description); Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description?) {
super.finished(description); Dispatchers.resetMain()
}
}
class LoginViewModelTest {
@get:Rule val mainRule = MainDispatcherRule()
@Test fun loadsItems() = runTest(mainRule.dispatcher) {
val vm = LoginViewModel(fakeRepo, mainRule.dispatcher)
vm.login(/* … */)
advanceUntilIdle()
assertIs<LoginUiState.Success>(vm.uiState.value)
}
}
Two-schedulers trap. MainDispatcherRule's dispatcher and the default dispatcher runTest { } creates have separate TestCoroutineSchedulers. Pass mainRule.dispatcher into runTest(...) so Dispatchers.Main and the test body share one scheduler — otherwise advanceUntilIdle() only flushes one of them and assertions race the ViewModel.
Prefer StandardTestDispatcher over UnconfinedTestDispatcher — it queues continuations (matching runTest semantics). Reach for UnconfinedTestDispatcher only when hot-flow collector eagerness is genuinely the point.
Use createComposeRule() for component tests, createAndroidComposeRule() for integration tests needing Activity.
Prefer v2 entry points for new code. Import from androidx.compose.ui.test.junit4.v2.createComposeRule (or androidx.compose.ui.test.v2.runComposeUiTest) — they use StandardTestDispatcher and match kotlinx.coroutines.test.runTest semantics. The v1 imports (androidx.compose.ui.test.junit4.createComposeRule, androidx.compose.ui.test.runComposeUiTest) use UnconfinedTestDispatcher and are deprecated WARNING. After migrating, a LaunchedEffect that previously ran eagerly may need an explicit mainClock.advanceTimeBy(0) or runCurrent() to drain queued work.
testTag as fallbackPrefer user-visible semantics — text, contentDescription, role, focused/toggled/selected state — over testTag. Real users (and screen readers) see semantics; testTag is invisible to everyone except tests.
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun `given loading state, when screen renders, then shows progress indicator`() {
// Given
val inputState = LoginUiState.Loading
// When
composeTestRule.setContent {
LoginScreen(uiState = inputState, onLogin = {})
}
// Then — semantics-first: assert what the user sees
composeTestRule.onNodeWithContentDescription("Loading").assertIsDisplayed()
}
Selector priority (top of list wins):
onNodeWithText("...") — visible text the user reads.onNodeWithContentDescription("...") — accessibility label (icons, images).hasClickAction(), isSelected(), isFocused(), isEnabled().onNodeWithTag("...") — only when there's no stable user-visible text or the text is duplicated/ambiguous (lists of identical rows, dynamic copy that changes per locale, multiple instances of the same component).Test what the user perceives, not implementation details. A test that asserts text the user sees survives refactors that move components around; a test that asserts onNodeWithTag("loading_indicator") breaks the moment the tag changes — and doesn't catch the user-facing regression where the spinner is wired wrong.
Counterargument worth knowing. skydoves/android-testing-skills argues for tag-first finders backed by androidx/material3's own ratios (1825 onNodeWithTag vs 424 onNodeWithText vs 46 onNodeWithContentDescription) — the tag-first stance is more robust to i18n rotation and copy edits. We still recommend semantics-first because text/contentDescription assertions exercise accessibility and catch a class of bugs testTag can never see — but if your app has separate accessibility coverage and prioritises i18n robustness, the tag-first stance is defensible.
Test that a click fires the expected callback — don't route the assertion through a ViewModel mock.
@Test
fun `tapping article row invokes onArticleClick with article id`() {
var clickedId: String? = null
val article = Article(id = "42", title = "Hello")
composeTestRule.setContent {
ArticleRow(article = article, onArticleClick = { clickedId = it })
}
composeTestRule.onNodeWithText("Hello").performClick()
assertEquals("42", clickedId)
}
The composable's contract is "render state, emit callbacks." Test exactly that.
| API | Source | When |
|---|---|---|
| mainClock.advanceTimeUntil(timeoutMillis) { condition } | Test clock — advances frame-by-frame | Compose-state-observable conditions (state.value == Done); deterministic, fast |
| rule.waitUntil(timeoutMillis) { condition } | Wall clock + 10 ms sleep per iteration | Non-Compose conditions (Job.isCompleted, an external counter) |
| rule.waitUntilExactlyOneExists(matcher, timeoutMillis) (experimental) | Wall clock | "Wait until exactly one node matches"; cleaner than hand-rolling waitUntil over fetchSemanticsNodes() |
Mixing wall-clock and test-clock waits in the same test is a common flake source. For any condition observable through Compose state, mainClock.advanceTimeUntil is correct.
Animation tests require mainClock.autoAdvance = false set before setContent. Otherwise the framework's InfiniteAnimationPolicy throws CancellationException on indeterminate animations, and finite animations finish in one auto-advanced burst with no observable intermediate state. After pausing the clock, drive frames with advanceTimeByFrame() (kick-off) then advanceTimeBy(durationMillis).
Test the smallest UI contract that proves the behavior.
A plain UI Compose test (createComposeRule() + state + callbacks) is enough for most behaviour. Reach for an integration test, screenshot test, or key-input test only when that shape is the one being proven.
| Thing being proven | Test shape |
|---|---|
| Text rendered, conditional content visible, loading/error branches, callback wiring from clicks | Plain UI Compose test (state + callbacks, no graph) |
| Focus navigation, keyboard, TV/D-pad behavior | Compose test with key input — drive with performKeyInput, assert with assertIsFocused(). See compose/references/focus-navigation.md |
| Visual contract semantics can't prove: spacing, themed colors, typography, elevation, gradients, focus highlight, skeleton loaders | Screenshot test, one per meaningful state |
| State holder updates UI correctly | State-holder unit test + ONE wiring smoke test (two tests, not one big integration) |
| Lifecycle, navigation, or DI integration itself under test | Integration test (createAndroidComposeRule, Hilt rule, real graph) |
Screenshot determinism rules:
Clock.fixed(...)) and animation progress (disable animations or set fixed progress)setContentWithFakeImageLoader { ... } (see android-skills:coil-compose)Fake image loader pattern:
@Test
fun `article row renders with fake image`() {
composeTestRule.setContent {
AppTheme {
val imageLoader = FakeImageLoader(LocalContext.current) // Coil 3 test util
CompositionLocalProvider(LocalImageLoader provides imageLoader) {
ArticleRow(article = previewArticle(), onClick = {})
}
}
}
composeTestRule.onRoot().captureRoboImage()
}
Use @HiltAndroidTest with HiltAndroidRule to inject real Hilt dependencies in instrumented tests.
@HiltAndroidTest
class UserRepositoryTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Inject
lateinit var database: AppDatabase
private lateinit var dao: UserDao
@Before
fun setUp() {
hiltRule.inject()
dao = database.userDao()
}
@Test
fun `given user saved, when queried by id, then returns user`() = runTest {
// Given
val inputUser = User(id = "1", name = "Alice")
dao.insert(inputUser)
// When
val actualUser = dao.findById("1")
// Then
assertEquals(inputUser, actualUser)
}
}
For unit tests with Hilt (without instrumentation), use @TestInstallIn to replace modules with fakes.
Roborazzi runs screenshot tests on the JVM via Robolectric — no emulator required. Use it to catch visual regressions.
# libs.versions.toml
[versions]
roborazzi = "1.x.x"
[plugins]
roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }
[libraries]
roborazzi = { group = "io.github.takahirom.roborazzi", name = "roborazzi", version.ref = "roborazzi" }
// module build.gradle.kts
plugins {
alias(libs.plugins.roborazzi)
}
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [33], qualifiers = RobolectricDeviceQualifiers.Pixel5)
class LoginScreenScreenshotTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `given loading state, captures loading screen`() {
// Given
composeTestRule.setContent {
AppTheme {
LoginScreen(uiState = LoginUiState.Loading, onLogin = {})
}
}
// Then
composeTestRule.onRoot().captureRoboImage()
}
}
Screenshot tests exercise visual state you can't assert with assertIsDisplayed(). Write one per meaningful UI state (loading, error, success, empty).
# Run JVM unit tests (fast — run on every change)
./gradlew test
# Run unit tests for a specific module
./gradlew :feature:login:test
# Run a single test class
./gradlew :feature:login:test --tests "com.example.login.LoginViewModelTest"
# Run instrumented tests (requires connected device/emulator)
./gradlew connectedAndroidTest
# Run instrumented tests for a specific module
./gradlew :feature:login:connectedAndroidTest
# Record screenshot baselines
./gradlew recordRoborazziDebug
# Verify screenshots against baselines (CI)
./gradlew verifyRoborazziDebug
class LoginViewModelTest {
private val fakeRepository = FakeAuthRepository()
private val viewModel = LoginViewModel(
repository = fakeRepository,
dispatcher = UnconfinedTestDispatcher()
)
@Test
fun `given valid credentials, when logging in, then emits success state`() = runTest {
// Given
fakeRepository.willSucceedFor(inputEmail = "[email protected]")
// When
viewModel.login(inputEmail = "[email protected]", inputPassword = "pass123")
// Then
val actualState = viewModel.uiState.value
assertIs<LoginUiState.Success>(actualState)
}
}
| Excuse | Reality |
|--------|---------|
| "Instrumented tests are slow, I'll skip" | Slow tests > no tests. Optimise test selection, don't skip. |
| "Mocks are easier to write than fakes" | Fakes are more valuable and readable. Invest once, reuse everywhere. |
| "I can't test this ViewModel, it uses Dispatchers.Main" | Inject the dispatcher. Hard to test = design smell. |
| "This Composable is too simple to test" | Simple UI breaks. A setContent + assertIsDisplayed takes 2 minutes. |
| "Room DAOs test themselves" | Write DAO tests with in-memory database to verify your queries. |
Thread.sleep() in any testrunBlocking instead of runTestonNodeWithTag used as the first reach when stable user-visible text or contentDescription existstestTag sprinkled across every interactive component "for testability" — semantics firstcompose/references/focus-navigation.md)assertIsFocused() — see compose/references/focus-navigation.mdtesting
Use when implementing paginated lists in Android or Compose with Paging 3 — PagingSource, Pager and PagingConfig setup, RemoteMediator for offline-first lists, LazyPagingItems and itemKey integration in LazyColumn, dynamic filters via flatMapLatest, and unit tests with TestPager and asSnapshot. Triggers include Paging 3, infinite list, infinite scroll, paginated list, LazyPagingItems, collectAsLazyPagingItems, and cachedIn.
development
Use when setting up or working with Koin in Android or KMP projects — module declarations with Classic DSL or KSP annotations, ViewModel injection in Compose, scopes, Nav 3 entry providers, application startup, and compile-time verification via `verify()`. Triggers on Koin, `single`, `factory`, `koinViewModel`, `koinInject`, `parametersOf`, `startKoin`, "KMP DI", "shared DI".
development
Use when persisting key-value preferences or small typed settings on Android or KMP with Jetpack DataStore — Preferences vs Typed (Proto/JSON) selection, KMP factory with per-platform file paths, SharedPreferences migration, serializers with corruption handlers, DI singletons, and repository/MVI integration. Triggers on DataStore, Preferences, PreferenceDataStoreFactory, DataStoreFactory, preferencesDataStore, SharedPreferencesMigration, Serializer, or persistent settings work.
development
Use when writing, fixing, or refactoring Android/KMP code in Kotlin — supplements superpowers:test-driven-development with Android's three-tier test model, fake-first strategy, coroutine testing, and Compose UI testing.