Skip to main content

android-testing

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

Zur Installation springen

Quellinformationen

Repository
tomevault-io/skills-registry
Letzte Quellaktivität
28. April 2026 um 22:53
Erkannte Sprache von SKILL.md
Englisch
Sterne
0
Forks
0

Installationsoptionen

Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.

Quelldateien prüfen

Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.

Datei-Explorer
2 Dateien

SKILL.md wird angezeigt

SKILL.md
Quellanweisungen · Schreibgeschützte Vorschau
name
android-testing
description
Best practices for Android instrumentation tests, Hilt testing, Mockito configuration, and test resilience patterns in Upnext. Use when this capability is needed.
metadata
{"author":"akitikkx"}
# 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]
Auf GitHub ansehen
Diese SKILL.md ist sehr gross, daher zeigt SkillsMP hier nur den ersten Abschnitt. Auf GitHub ansehen