.agent/skills/android_testing/SKILL.md
Best practices for Android instrumentation tests, Hilt testing, Mockito configuration, and test resilience patterns in Upnext.
npx skillsauth add akitikkx/upnext Android TestingInstall 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.
This skill provides guidance for setting up and maintaining a reliable Android test suite, including instrumentation tests, Hilt integration, and handling common pitfalls.
Upnext has two test source sets:
| Source Set | Location | Purpose | Framework |
|------------|----------|---------|-----------|
| Unit Tests | app/src/test/ | Fast, JVM-only tests | JUnit, Mockito, Robolectric |
| Instrumentation Tests | app/src/androidTest/ | On-device/emulator tests | AndroidJUnit4, Compose Testing |
[!IMPORTANT] Do not duplicate tests between source sets. If a test can run as a unit test with Robolectric, prefer that over instrumentation tests for speed and reliability.
When using KSP for Hilt annotation processing, avoid configuring androidTestAnnotationProcessor for the same compiler. This causes Hilt to generate files twice, resulting in:
error: [Hilt] Attempt to recreate a file for type hilt_aggregated_deps._com_example_Test_GeneratedInjector
In app/build.gradle:
// ✅ CORRECT: Use KSP only for both main and androidTest
ksp libs.hilt.android.compiler
kspAndroidTest libs.hilt.android.compiler
kspTest libs.hilt.android.compiler
// ❌ WRONG: Don't mix KSP with annotation processor
// androidTestAnnotationProcessor libs.hilt.android.compiler // REMOVE THIS
For instrumentation tests that use Hilt:
@RunWith(AndroidJUnit4::class)
@HiltAndroidTest
class MyInstrumentedTest {
@get:Rule(order = 0)
val hiltTestRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Before
fun init() {
hiltTestRule.inject()
}
}
Standard mockito-core fails on Android with:
java.lang.IllegalStateException: Could not initialize plugin: interface org.mockito.plugins.MockMaker
In gradle/libs.versions.toml:
mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockitoCore" }
mockito-android = { module = "org.mockito:mockito-android", version.ref = "mockitoCore" }
mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlin" }
In app/build.gradle:
// Unit tests (JVM)
testImplementation libs.mockito.core
testImplementation libs.mockito.kotlin
// Instrumentation tests (Device/Emulator)
androidTestImplementation libs.mockito.android // ✅ Not mockito-core!
androidTestImplementation libs.mockito.kotlin
UI tests that depend on network data or specific app state should skip gracefully rather than fail:
import org.junit.Assume.assumeTrue
@Test
fun testThatRequiresData() {
// Wait for data, skip if unavailable
val hasData = try {
composeTestRule.waitUntil(timeoutMillis = 10000) {
composeTestRule.onAllNodesWithContentDescription("Show poster")
.fetchSemanticsNodes().isNotEmpty()
}
true
} catch (e: ComposeTimeoutException) {
false
}
assumeTrue("Skipping: No data available", hasData)
// Continue with test...
}
When a test is temporarily broken or duplicated:
import org.junit.Ignore
@Ignore("Duplicate of unit test. Use the /test/ version instead.")
class DeprecatedTest {
// ...
}
When testing ViewModels handling modern Kotlin StateFlow and Coroutines:
.thenReturn(flowOf(data)) for Repositories instead of standard returns.runTest context and use UnconfinedTestDispatcher if you need to actively collect underlying StateFlow streams.InstantTaskExecutorRule() for LiveData and CoroutineTestRule() for dispatchers.@Test
fun `repository state triggers side-effects`() = runTest {
// Mock upstream StateFlow
whenever(repository.flowData).thenReturn(flowOf(mockData))
// Create viewmodel under test
val viewModel = MyViewModel(repository)
// IMPORTANT: Collect the hot flow so stateIn() bindings actually execute
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.isAuthorized.collect {}
}
// Act
viewModel.onEventFire()
// Assert
verify(repository).saveData()
}
When a ViewModel hardcodes coroutine launches inside Dispatchers.IO (instead of using injected customizable dispatchers), standard test functions like advanceUntilIdle() will fail to pause tests appropriately because Dispatchers.IO executes decoupled from runTest's StandardTestDispatcher.
To accurately suspend your test framework until the background Dispatchers.IO task updates the nested UI state variables securely, chain a parameter condition block using first { ... } instead of rigid .value extractions:
@Test
fun `hardcoded background IO dispatchers suspend safely`() = runTest {
// Act (Spins up on an unsynchronized `Dispatchers.IO` Thread)
viewModel.onEventFire()
// Assert
// Suspend execution gracefully until the nested background job emits non-null bounds
val uiState = viewModel.uiState.first { it.errorMessage != null }
assertEquals("Failed", uiState.errorMessage)
}
Don't launch real HTTP calls or real WorkManager workers in Unit Tests. Rather:
WorkManager object.OneTimeWorkRequestBuilder execution parameters explicitly. Because WorkManager.enqueue() is overloaded (it accepts single requests OR lists), standard Mockito.any() will cause a Kotlin compilation error Overload resolution ambiguity. You must use mockito-kotlin's reified type parameters: verify(workManager).enqueue(org.mockito.kotlin.any<androidx.work.WorkRequest>()).Result.Success, make sure to wrap your mock inside flowOf(Result.Success()).When a ViewModel triggers a remote refresh (e.g., pulling watched state from Trakt), test both the happy path and the unauthenticated path:
@Test
fun `screen load triggers server refresh when authenticated`() = runTest {
val token = TraktAccessToken(access_token = "token", ...)
whenever(traktRepository.traktAccessToken).thenReturn(MutableStateFlow(token))
whenever(repository.refreshFromRemote("token", id)).thenReturn(Result.success(Unit))
createViewModel()
viewModel.loadData(args)
testScheduler.advanceUntilIdle()
verify(repository).refreshFromRemote("token", id)
}
@Test
fun `screen load does NOT call refresh when no token`() = runTest {
whenever(traktRepository.traktAccessToken).thenReturn(MutableStateFlow(null))
createViewModel()
viewModel.loadData(args)
testScheduler.advanceUntilIdle()
verify(repository, never()).refreshFromRemote(any(), any())
}
For Compose UI tests, add required experimental annotations:
@OptIn(
androidx.compose.animation.ExperimentalAnimationApi::class,
androidx.compose.foundation.ExperimentalFoundationApi::class,
androidx.compose.ui.test.ExperimentalTestApi::class,
androidx.compose.ui.ExperimentalComposeUiApi::class,
androidx.compose.material3.ExperimentalMaterial3Api::class,
androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi::class
)
class MyComposeTest {
// ...
}
Top-level imports for extension functions like assertExists or fetchSemanticsNodes can fail to resolve in CI environments (e.g., GitHub Actions) despite working locally.
// ❌ RISKY: May fail in CI
import androidx.compose.ui.test.assertExists
import androidx.compose.ui.test.fetchSemanticsNodes
composeTestRule.onNodeWithTag("tag").assertExists()
Use standard method chaining syntax instead of relying on static imports for extension functions. This ensures the compiler resolves the function on the object type directly.
// ✅ SAFE: Resolves reliably
import androidx.compose.ui.test.onNodeWithTag
composeTestRule
.onNodeWithTag("tag")
.assertExists() // Resolved as method call on SemanticsNodeInteraction
| Issue | Cause | Fix |
|-------|-------|-----|
| Attempt to recreate a file | Mixed KSP + KAPT | Remove androidTestAnnotationProcessor |
| MockMaker initialization failed | Using mockito-core on Android | Use mockito-android |
| ComposeTimeoutException | No data in UI test | Use assumeTrue to skip |
| Tests pass locally, fail in CI | Network/data differences | Use mock data or assumeTrue |
When Hilt generates stale files:
./gradlew clean :app:connectedDebugAndroidTest
# Single test class
./gradlew :app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.theupnextapp.NavigationBackStackTest
# All androidTest tests
./gradlew :app:connectedDebugAndroidTest
UI tests run automatically on Pull Requests via .github/workflows/pull_request.yml.
The workflow uses reactivecircus/android-emulator-runner:
- name: Run Instrumented Tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 30
arch: x86_64
profile: pixel_6
target: google_apis
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
disable-animations: true
script: ./gradlew :app:connectedDebugAndroidTest --continue
[!WARNING] Always scope to
:app:module when running instrumented tests. RunningconnectedDebugAndroidTestwithout module prefix will execute tests in all modules (includingcore:common,core:data), which may have different test configurations and cause failures.
assumeTrue will show as skipped (not failed) when data is unavailableTest stateless composables directly with createComposeRule() (no Hilt, no ViewModel). This is the fastest and most reliable approach:
@get:Rule
val rule = createComposeRule()
@Test
fun searchArea_withResults_displaysShowTitles() {
rule.setContent {
SearchArea(
searchResultsList = listOf(mockShowSearch(1, "Breaking Bad")),
recentSearches = null,
onTextSubmit = {},
onResultClick = {},
onRecentSearchClick = {},
onClearRecentSearches = {},
)
}
rule.onNodeWithText("Breaking Bad").assertIsDisplayed()
}
| Screen | Composable | Parameters |
|--------|-----------|------------|
| Search | SearchArea() | Lists + callbacks |
| ShowDetail | ShowDetailButtons() | Booleans + callbacks |
| ShowDetail | ErrorState() | message + onRetry |
| ShowDetail | EpisodeSummary() | summary string |
| Watchlist | WatchlistListContent() | Items + filter state + callbacks |
| Account | AccountContent() | Full screen state + callbacks |
When testing ViewModels with combine() pipelines (e.g., search + sort + filter), use FakeTraktRepository and collect the StateFlow:
@Test
fun `status filter and sort combine correctly`() = runTest {
val mockShows = listOf(
TraktUserListItem(id = 1, title = "Zebra", status = "Returning Series", ...),
TraktUserListItem(id = 2, title = "Apple", status = "Returning Series", ...),
TraktUserListItem(id = 3, title = "Mango", status = "Ended", ...),
)
traktRepository.setWatchlistShows(mockShows)
viewModel = TraktAccountViewModel(traktRepository, workManager, traktAuthManager)
val job = launch { viewModel.watchlistShows.collect {} }
advanceUntilIdle()
viewModel.onStatusFilterChange("Returning Series")
viewModel.onSortOptionChange(WatchlistSortOption.TITLE)
advanceUntilIdle()
val result = viewModel.watchlistShows.value
assert(result.size == 2)
assert(result[0].title == "Apple")
job.cancel()
}
[!TIP] Always
launch { flow.collect {} }before asserting onStateFlow.value—stateIn()flows are cold until collected.
When testing FilterChip composables, use callback capture:
@Test
fun accountContent_statusFilterChips_renderAndFilter() {
var selectedStatus: String? = null
rule.setContent {
AccountContent(
watchlistStatusFilter = null,
availableStatuses = listOf("Ended", "Returning Series"),
onStatusFilterChange = { selectedStatus = it },
// ... other params with defaults
)
}
rule.onNodeWithText("All").assertIsDisplayed()
rule.onNodeWithText("Ended").performClick()
assert(selectedStatus == "Ended")
}
[!IMPORTANT]
AccountContentandWatchlistListContentuse default parameters for filter-related props (statusFilter = null,availableStatuses = emptyList(), etc.). Existing tests that don't pass these values will continue to compile and function correctly.
testing
Create, edit, improve, or audit AgentSkills. Use when creating a new skill from scratch or when asked to improve, review, audit, tidy up, or clean up an existing skill or SKILL.md file. Also use when editing or restructuring a skill directory (moving files to references/ or scripts/, removing stale content, validating against the AgentSkills spec). Triggers on phrases like "create a skill", "author a skill", "tidy up a skill", "improve this skill", "review the skill", "clean up the skill", "audit the skill".
testing
Host security hardening and risk-tolerance configuration for OpenClaw deployments. Use when a user asks for security audits, firewall/SSH/update hardening, risk posture, exposure review, OpenClaw cron scheduling for periodic checks, or version status checks on a machine running OpenClaw (laptop, workstation, Pi, VPS).
testing
Create, edit, improve, or audit AgentSkills. Use when creating a new skill from scratch or when asked to improve, review, audit, tidy up, or clean up an existing skill or SKILL.md file. Also use when editing or restructuring a skill directory (moving files to references/ or scripts/, removing stale content, validating against the AgentSkills spec). Triggers on phrases like "create a skill", "author a skill", "tidy up a skill", "improve this skill", "review the skill", "clean up the skill", "audit the skill".
testing
Host security hardening and risk-tolerance configuration for OpenClaw deployments. Use when a user asks for security audits, firewall/SSH/update hardening, risk posture, exposure review, OpenClaw cron scheduling for periodic checks, or version status checks on a machine running OpenClaw (laptop, workstation, Pi, VPS).