| name | Android Testing |
| description | Best practices for Android instrumentation tests, Hilt testing, Mockito configuration, and test resilience patterns in Upnext. |
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()
}
}
🛠️ WorkManager Initialization in Hilt Tests
If your ViewModel or class under test triggers WorkManager resolution (e.g. through injection of WorkManager), running Hilt tests with the standard HiltTestApplication (which does not implement Configuration.Provider) will throw IllegalStateException: WorkManager is not initialized properly.
✅ Correct Pattern:
Manually configure and initialize WorkManager inside the @Before method of your test class before calling hiltTestRule.inject(). Wrap it in a try-catch to prevent crashes when tests run sequentially in the same test process:
@Before
fun init() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
try {
val config = Configuration.Builder()
.setMinimumLoggingLevel(android.util.Log.DEBUG)
.build()
WorkManager.initialize(context, config)
} catch (e: IllegalStateException) {
}
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() {
val hasData = try {
composeTestRule.waitUntil(timeoutMillis = 10000) {
composeTestRule.onAllNodesWithContentDescription("Show poster")
.fetchSemanticsNodes().isNotEmpty()
}
true
} catch (e: ComposeTimeoutException) {
false
}
assumeTrue("Skipping: No data available", hasData)
}
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
runTest context and use UnconfinedTestDispatcher if you need to actively collect underlying StateFlow streams.
- Don't forget
InstantTaskExecutorRule() for LiveData and CoroutineTestRule() for dispatchers.
@Test
fun `repository state triggers side-effects`() = runTest {
whenever(repository.flowData).thenReturn(flowOf(mockData))
val viewModel = MyViewModel(repository)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.isAuthorized.collect {}
}
viewModel.onEventFire()
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 {
viewModel.onEventFire()
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
WorkManager object.
- Validate the
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>()).
- If an API returns
Result.Success, make sure to wrap your mock inside flowOf(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.
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.
import androidx.compose.ui.test.onNodeWithTag
composeTestRule
.onNodeWithTag("tag")
.assertExists()
🔧 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
./gradlew :app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.theupnextapp.NavigationBackStackTest
./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. Running connectedDebugAndroidTest without module prefix will execute tests in all modules (including core: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
assumeTrue will show as skipped (not failed) when data is unavailable
📁 Key Files
🎯 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,
onTextSubmit = {},
onResultClick = {},
onRecentSearchClick = {},
onClearRecentSearches = {},
)
}
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 on StateFlow.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"),
onStatusFilterChange = { selectedStatus = it },
)
}
rule.onNodeWithText("All").assertIsDisplayed()
rule.onNodeWithText("Ended").performClick()
assert(selectedStatus == "Ended")
}
[!IMPORTANT]
AccountContent and WatchlistListContent use 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.