# Android Testing

> Best practices for Android instrumentation tests, Hilt testing, Mockito configuration, and test resilience patterns in Upnext. Use when this capability is needed.

- Skill: `tomevault-io/android-testing-2` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add tomevault-io/android-testing-2`
- Raw SKILL.md: https://api.skillmd.com/api/skills/tomevault-io/android-testing-2/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: tomevault-io (https://skillmd.com/u/tomevault-io)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/tomevault-io/android-testing-2

---


# 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`:

```groovy
// ✅ 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:

```kotlin
@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`:

```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`:

```groovy
// 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:

```kotlin
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:

```kotlin
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:

1. Use `.thenReturn(flowOf(data))` for Repositories instead of standard returns.
2. Ensure you initialize the `runTest` context and use `UnconfinedTestDispatcher` if you need to actively collect underlying `StateFlow` streams.
3. Don't forget `InstantTaskExecutorRule()` for `LiveData` and `CoroutineTestRule()` for dispatchers.

```kotlin
@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:

```kotlin
@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:

1. Inject an interface or fully mock the `WorkManager` object.
2. 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>())`.
3. 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:

```kotlin
@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:

```kotlin
@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.

```kotlin
// ❌ 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.

```kotlin
// ✅ 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:

```bash
./gradlew clean :app:connectedDebugAndroidTest
```

### Running Specific Tests

```bash
# 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`:

```yaml
- 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

- [app/build.gradle](file:///Users/ahmedtikiwa/upnext4/app/build.gradle) - Test dependencies
- [libs.versions.toml](file:///Users/ahmedtikiwa/upnext4/gradle/libs.versions.toml) - Version catalog
- [CustomTestRunner.kt](file:///Users/ahmedtikiwa/upnext4/app/src/androidTest/java/com/theupnextapp/CustomTestRunner.kt) - Hilt test runner
- [pull_request.yml](file:///Users/ahmedtikiwa/upnext4/.github/workflows/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:

```kotlin
@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:

```kotlin
@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:

```kotlin
@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]
> `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.

---
> Converted and distributed by [TomeVault](https://tomevault.io/claim/akitikkx) — claim your Tome and manage your conversions.
<!-- tomevault:4.0:skill_md:2026-04-11 -->

