Overview
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.
Android's Three Testing Tiers
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.
Fakes Over Mocks
Prefer hand-written fakes to mocking frameworks.
Fakes implement the real interface with in-memory behaviour. They are:
- Easier to read and reason about
- Reusable across many tests
- Immune to internal implementation changes
- Not tied to call verification (which tests implementation, not behaviour)
Use a mock only when:
- The dependency has no meaningful state (pure function delegation)
- Verifying a specific interaction IS the behaviour under test (e.g. analytics events)
- Creating a fake would require duplicating complex external library logic
// 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
Assertion Library
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.
Coroutine Testing
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 = "bad@email.com", inputPassword = "wrong")
advanceUntilIdle()
// Then
val actualState = viewModel.uiState.value
assertIs<LoginUiState.Error>(actualState)
}
For StateFlow / SharedFlow collection, use Turbine:
viewModel.uiState.test {
assertIs<LoginUiState.Loading>(awaitItem())
assertIs<LoginUiState.Error>(awaitItem())
cancelAndIgnoreRemainingEvents()
}
Dispatcher injection is required for testability:
class LoginViewModel(
private val repository: AuthRepository,
private val dispatcher: CoroutineDispatcher = Dispatchers.Main
) : ViewModel()
In tests, inject UnconfinedTestDispatcher or StandardTestDispatcher from kotlinx-coroutines-test.
Compose UI Testing
Use createComposeRule() for component tests, createAndroidComposeRule() for integration tests needing Activity.
@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
composeTestRule.onNodeWithTag("loading_indicator").assertIsDisplayed()
}
Use semantics / testTag in production Composables to avoid brittle text-based selectors.
Hilt Testing
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.
Screenshot Testing with Roborazzi
Roborazzi runs screenshot tests on the JVM via Robolectric — no emulator required. Use it to catch visual regressions.
Setup
# 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)
}
Writing a Screenshot Test
@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).
Gradle Commands
# 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
ViewModel Test Template (Given-When-Then)
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 = "user@example.com")
// When
viewModel.login(inputEmail = "user@example.com", inputPassword = "pass123")
// Then
val actualState = viewModel.uiState.value
assertIs<LoginUiState.Success>(actualState)
}
}
Common Rationalizations
| 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. |
Red Flags
- Using
Thread.sleep()in any test - Using
runBlockinginstead ofrunTest - Mocking data classes or simple value types
- No
testTagon interactive/key UI components - Instrumented tests for logic that could run on JVM