Android Testing Skill
This skill provides guidance for setting up and maintaining a reliable Android test suite, including instrumentation tests, Hilt integration, and handling common pitfalls.
🧪 Test Suite Structure
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.
⚙️ Hilt Testing Configuration
The Problem: Duplicate File Generation
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
Correct Configuration
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
Hilt Test Setup
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()
}
}
🎭 Mockito Configuration
The Problem: MockMaker Plugin Initialization
Standard mockito-core fails on Android with:
java.lang.IllegalStateException: Could not initialize plugin: interface org.mockito.plugins.MockMaker
Solution: Use mockito-android
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
🛡️ Test Resilience Patterns
Using assumeTrue for Graceful Skipping
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...
}
Using @Ignore for Deprecated Tests
When a test is temporarily broken or duplicated:
import org.junit.Ignore
@Ignore("Duplicate of unit test. Use the /test/ version instead.")
class DeprecatedTest {
// ...
}
🚦 ViewModel & Coroutine Testing
Testing Unidirectional Data Flow & StateFlows
When testing ViewModels handling modern Kotlin StateFlow and Coroutines:
- Use
.thenReturn(flowOf(data))for Repositories instead of standard returns. - Ensure you initialize the
runTestcontext and useUnconfinedTestDispatcherif you need to actively collect underlyingStateFlowstreams. - Don't forget
InstantTaskExecutorRule()forLiveDataandCoroutineTestRule()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()
}
Unsynchronized Background Threads & StateFlows
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)
}
Mocking WorkManager & Remote API Calls
Don't launch real HTTP calls or real WorkManager workers in Unit Tests. Rather:
- Inject an interface or fully mock the
WorkManagerobject. - Validate the
OneTimeWorkRequestBuilderexecution parameters explicitly. BecauseWorkManager.enqueue()is overloaded (it accepts single requests OR lists), standardMockito.any()will cause a Kotlin compilation errorOverload resolution ambiguity. You must usemockito-kotlin's reified type parameters:verify(workManager).enqueue(org.mockito.kotlin.any<androidx.work.WorkRequest>()). - If an API returns
Result.Success, make sure to wrap your mock insideflowOf(Result.Success()).
Testing Remote Data Refresh in ViewModels
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())
}
📋 Compose Testing OptIns
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 {
// ...
}
🧩 Compose Testing Imports
The Problem: Unresolved References in CI
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()
Solution: Method Chaining
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
🔧 Troubleshooting
Common Issues
| 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 |
Cleaning Build Artifacts
When Hilt generates stale files:
./gradlew clean :app:connectedDebugAndroidTest
Running Specific Tests
# Single test class
./gradlew :app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.theupnextapp.NavigationBackStackTest
# All androidTest tests
./gradlew :app:connectedDebugAndroidTest
🚀 CI/CD Integration
UI tests run automatically on Pull Requests via .github/workflows/pull_request.yml.
Configuration
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.
Key Notes
- KVM acceleration is enabled for faster emulator performance
- 45 minute timeout prevents hung tests from blocking PRs
- Test reports are uploaded as artifacts on failure
- Tests using
assumeTruewill show as skipped (not failed) when data is unavailable
📁 Key Files
- app/build.gradle - Test dependencies
- libs.versions.toml - Version catalog
- CustomTestRunner.kt - Hilt test runner
- pull_request.yml - CI workflow with UI tests
🎯 Stateless Composable Testing
The Strategy
Test 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,
)
}
rule.onNodeWithText("Breaking Bad").assertIsDisplayed()
}
Testable Composables in Upnext
| 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 |
🔄 Multi-Flow Combine Testing
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.
🏷️ FilterChip Interaction Testing
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"),
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.
Converted and distributed by TomeVault — claim your Tome and manage your conversions.