From fb18ac5aa161002e5f5fbdd14d7ce65da1cdb270 Mon Sep 17 00:00:00 2001 From: utzcoz Date: Sun, 22 Mar 2026 20:11:06 +0800 Subject: [PATCH] fix(android-native-dev): Add testing for android-native-dev skill --- skills/android-native-dev/SKILL.md | 101 ++++ .../android-native-dev/references/testing.md | 554 ++++++++++++++++++ 2 files changed, 655 insertions(+) create mode 100644 skills/android-native-dev/references/testing.md diff --git a/skills/android-native-dev/SKILL.md b/skills/android-native-dev/SKILL.md index a42a363..ddb3c28 100644 --- a/skills/android-native-dev/SKILL.md +++ b/skills/android-native-dev/SKILL.md @@ -780,3 +780,104 @@ See [Design Style Guide](references/design-style-guide.md) for detailed style pr | Privacy & Security | [Privacy & Security](references/privacy-security.md) | | Audio, Video, Notifications | [Functional Requirements](references/functional-requirements.md) | | App Style by Category | [Design Style Guide](references/design-style-guide.md) | + +--- + +## 8. Testing + +> **Note**: Only add test dependencies when the user explicitly asks for testing. + +A well-tested Android app uses layered testing: fast local unit tests for logic, instrumentation tests for UI and integration, and Gradle Managed Devices to run emulators reproducibly on any machine — including CI. + +### 8.1 Test Dependencies + +Before adding test dependencies, inspect the project's existing versions to avoid conflicts: + +1. Check `gradle/libs.versions.toml` — if present, add test deps using the project's version catalog style +2. Check existing `build.gradle.kts` for already-pinned dependency versions +3. Match version families using the table below + +**Version Alignment Rules**: + +| Test Dependency | Must Align With | How to Check | +|----------------------------------------------|--------------------------------------------------|-----------------------------------------------------------------------| +| `kotlinx-coroutines-test` | Project's `kotlinx-coroutines-core` version | Search for `kotlinx-coroutines` in build files or version catalog | +| `compose-ui-test-junit4` | Project's Compose BOM or `compose-compiler` | Search for `compose-bom` or `compose.compiler` in build files | +| `espresso-*` | All Espresso artifacts must use the same version | Search for `espresso` in build files | +| `androidx.test:runner`, `rules`, `ext:junit` | Should use compatible AndroidX Test versions | Search for `androidx.test` in build files | +| `mockk` | Must support the project's Kotlin version | Check `kotlin` version in root `build.gradle.kts` or version catalog | + +**Dependencies Reference** — add only the groups you need: + +```kotlin +dependencies { + // --- Local unit tests (src/test/) --- + testImplementation("junit:junit:") // 4.13.2+ + testImplementation("org.robolectric:robolectric:") // 4.16.1+ + testImplementation("io.mockk:mockk:") // match Kotlin version + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:") // match coroutines-core + testImplementation("androidx.arch.core:core-testing:") // InstantTaskExecutorRule for LiveData + testImplementation("app.cash.turbine:turbine:") // Flow/StateFlow testing + + // --- Instrumentation tests (src/androidTest/) --- + androidTestImplementation("androidx.test.ext:junit:") + androidTestImplementation("androidx.test:runner:") + androidTestImplementation("androidx.test:rules:") + androidTestImplementation("androidx.test.espresso:espresso-core:") + androidTestImplementation("androidx.test.espresso:espresso-contrib:") // RecyclerView, Drawer + androidTestImplementation("androidx.test.espresso:espresso-intents:") // Intent verification + androidTestImplementation("androidx.test.espresso:espresso-idling-resource:") + androidTestImplementation("androidx.test.uiautomator:uiautomator:") + + // --- Compose UI tests (only if project uses Compose) --- + androidTestImplementation("androidx.compose.ui:ui-test-junit4") // version from Compose BOM + debugImplementation("androidx.compose.ui:ui-test-manifest") // required for createComposeRule +} +``` + +> **Note**: If the project uses a Compose BOM, `ui-test-junit4` and `ui-test-manifest` don't need explicit versions — the BOM manages them. + +Enable Robolectric resource support in the `android` block: + +```kotlin +android { + testOptions { + unitTests.isIncludeAndroidResources = true // required for Robolectric + } +} +``` + +### 8.2 Testing by Layer + +| Layer | Location | Runs On | Speed | Use For | +|--------------------|--------------------|-------------------------|----------------------|--------------------------------------------------| +| Unit (JUnit) | `src/test/` | JVM | ~ms | ViewModels, repos, mappers, validators | +| Unit + Robolectric | `src/test/` | JVM + simulated Android | ~100ms | Code needing Context, resources, SharedPrefs | +| Compose UI (local) | `src/test/` | JVM + Robolectric | ~100ms | Composable rendering & interaction | +| Espresso | `src/androidTest/` | Device/Emulator | ~seconds | View-based UI flows, Intents, DB integration | +| Compose UI (device)| `src/androidTest/` | Device/Emulator | ~seconds | Full Compose UI flows with real rendering | +| UI Automator | `src/androidTest/` | Device/Emulator | ~seconds | System dialogs, notifications, multi-app | +| Managed Device | `src/androidTest/` | Gradle-managed AVD | ~minutes (first run) | CI, matrix testing across API levels | + +See [Testing](references/testing.md) for detailed examples, code patterns, and Gradle Managed Device configuration. + +### 8.3 Testing Commands + +```bash +# Local unit tests (fast, no emulator) +./gradlew test # all modules +./gradlew :app:testDebugUnitTest # app module, debug variant + +# Single test class +./gradlew :app:testDebugUnitTest --tests "com.example.myapp.CounterViewModelTest" + +# Instrumentation tests (requires device or managed device) +./gradlew connectedDebugAndroidTest # on connected device +./gradlew pixel6Api34DebugAndroidTest # on managed device + +# Both together +./gradlew test connectedDebugAndroidTest + +# Test with coverage report (JaCoCo) +./gradlew testDebugUnitTest jacocoTestReport +``` diff --git a/skills/android-native-dev/references/testing.md b/skills/android-native-dev/references/testing.md new file mode 100644 index 0000000..8543f86 --- /dev/null +++ b/skills/android-native-dev/references/testing.md @@ -0,0 +1,554 @@ +# Testing + +Detailed examples and patterns for each Android test layer. Read the section relevant to the layer you're working with. + +## Table of Contents + +1. [Local Unit Tests (JUnit + Robolectric)](#1-local-unit-tests-junit--robolectric) +2. [Instrumentation Tests (Espresso)](#2-instrumentation-tests-espresso) +3. [UI Automator (Cross-App & System UI)](#3-ui-automator-cross-app--system-ui) +4. [Compose UI Testing](#4-compose-ui-testing) +5. [Gradle Managed Devices](#5-gradle-managed-devices) + +--- + +## 1. Local Unit Tests (JUnit + Robolectric) + +Local tests live in `src/test/` and run on the JVM — no emulator needed, so they're fast (milliseconds each). Use them for ViewModels, Repositories, mappers, validators, and any pure logic. + +### Basic ViewModel Test + +```kotlin +class CounterViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() // see below + + private lateinit var viewModel: CounterViewModel + + @Before + fun setup() { + viewModel = CounterViewModel() + } + + @Test + fun `increment updates count`() = runTest { + viewModel.increment() + assertEquals(1, viewModel.uiState.value.count) + } +} +``` + +### Testing Coroutines (Critical) + +The Main dispatcher doesn't exist on the JVM. Replace it with `TestDispatcher` or tests crash with `IllegalStateException`. + +```kotlin +// Reusable rule — put in a shared test-util module +class MainDispatcherRule( + private val dispatcher: TestDispatcher = UnconfinedTestDispatcher() +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(dispatcher) + } + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} +``` + +```kotlin +// ❌ Wrong: No Main dispatcher replacement → crash +@Test +fun `load data`() = runTest { + val vm = MyViewModel(repo) + vm.load() // launches on Dispatchers.Main → IllegalStateException +} + +// ✅ Correct: Use MainDispatcherRule +@get:Rule +val mainDispatcherRule = MainDispatcherRule() + +@Test +fun `load data`() = runTest { + val vm = MyViewModel(repo) + vm.load() + assertEquals(UiState.Success, vm.uiState.value) +} +``` + +### Testing StateFlow with Turbine + +```kotlin +@Test +fun `loading then success states`() = runTest { + val vm = MyViewModel(fakeRepo) + + vm.uiState.test { // Turbine extension + assertEquals(UiState.Idle, awaitItem()) + vm.load() + assertEquals(UiState.Loading, awaitItem()) + assertEquals(UiState.Success(data), awaitItem()) + cancelAndIgnoreRemainingEvents() + } +} +``` + +### Mocking with MockK + +```kotlin +@Test +fun `repository calls api and caches`() = runTest { + val api = mockk() + coEvery { api.getUser("42") } returns User("42", "Alice") + + val repo = UserRepository(api) + val user = repo.getUser("42") + + assertEquals("Alice", user.name) + coVerify(exactly = 1) { api.getUser("42") } +} +``` + +| MockK Function | Purpose | +|----------------|------------------------| +| `mockk()` | Create mock instance | +| `every { }` | Stub synchronous calls | +| `coEvery { }` | Stub suspend functions | +| `verify { }` | Verify call happened | +| `coVerify { }` | Verify suspend call | +| `slot()` | Capture argument value | + +### Robolectric — When You Need Android Classes + +Robolectric simulates the Android framework on the JVM, so tests stay fast while accessing `Context`, `SharedPreferences`, resources, etc. + +```kotlin +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class PreferencesManagerTest { + + private lateinit var context: Context + + @Before + fun setup() { + context = ApplicationProvider.getApplicationContext() + } + + @Test + fun `saves and reads theme preference`() { + val prefs = PreferencesManager(context) + prefs.setDarkMode(true) + assertTrue(prefs.isDarkMode()) + } +} +``` + +### Common Local Test Mistakes + +```kotlin +// ❌ Wrong: Testing implementation details (fragile) +@Test +fun `check internal cache map size`() { + repo.load() + assertEquals(1, repo.cacheMap.size) // breaks if cache strategy changes +} + +// ✅ Correct: Test observable behavior +@Test +fun `second call returns cached result without network`() = runTest { + coEvery { api.fetch() } returns data + + repo.load() + repo.load() + + coVerify(exactly = 1) { api.fetch() } // only one network call +} +``` + +--- + +## 2. Instrumentation Tests (Espresso) + +Instrumentation tests live in `src/androidTest/` and run on a real device or emulator. Slower than local tests, but they exercise the actual Android stack — use them for UI flows, database integration, and cross-component interaction. + +### Test Runner Setup + +In `app/build.gradle.kts`: + +```kotlin +android { + defaultConfig { + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } +} +``` + +### Espresso Basics + +Espresso's API follows a consistent pattern: **find → act → assert**. + +```kotlin +@RunWith(AndroidJUnit4::class) +class LoginScreenTest { + + @get:Rule + val activityRule = ActivityScenarioRule(LoginActivity::class.java) + + @Test + fun validLogin_navigatesToHome() { + // Find and act + onView(withId(R.id.email_input)) + .perform(typeText("user@example.com"), closeSoftKeyboard()) + onView(withId(R.id.password_input)) + .perform(typeText("secret123"), closeSoftKeyboard()) + onView(withId(R.id.login_button)) + .perform(click()) + + // Assert + onView(withId(R.id.home_container)) + .check(matches(isDisplayed())) + } +} +``` + +| Category | Common Matchers / Actions | +|------------|------------------------------------------------------------------------------------| +| **Find** | `withId(R.id.x)`, `withText("x")`, `withContentDescription("x")`, `withHint("x")` | +| **Act** | `click()`, `typeText("x")`, `clearText()`, `scrollTo()`, `swipeUp()` | +| **Assert** | `isDisplayed()`, `withText("x")`, `isEnabled()`, `isChecked()`, `doesNotExist()` | + +### Testing Intents + +Espresso-Intents lets you verify outgoing Intents and stub responses (e.g., camera, file picker). + +```kotlin +@get:Rule +val intentsRule = IntentsRule() + +@Test +fun shareButton_launchesShareIntent() { + onView(withId(R.id.share_button)).perform(click()) + + intended(allOf( + hasAction(Intent.ACTION_SEND), + hasType("text/plain") + )) +} + +@Test +fun cameraButton_handlesResult() { + val resultData = Intent().apply { putExtra("photo_uri", "content://mock") } + intending(hasAction(MediaStore.ACTION_IMAGE_CAPTURE)) + .respondWith(Instrumentation.ActivityResult(RESULT_OK, resultData)) + + onView(withId(R.id.camera_button)).perform(click()) + onView(withId(R.id.photo_preview)).check(matches(isDisplayed())) +} +``` + +### IdlingResource for Async Operations + +Espresso waits for the UI thread and AsyncTask by default, but not for custom async work (Retrofit, coroutines, etc.). `IdlingResource` tells Espresso when your app is busy. + +```kotlin +// In production code (thin wrapper) +object NetworkIdlingResource { + private val counter = CountingIdlingResource("Network") + fun increment() = counter.increment() + fun decrement() = counter.decrement() + fun get(): IdlingResource = counter +} + +// In test setup +@Before +fun registerIdling() { + IdlingRegistry.getInstance().register(NetworkIdlingResource.get()) +} + +@After +fun unregisterIdling() { + IdlingRegistry.getInstance().unregister(NetworkIdlingResource.get()) +} +``` + +--- + +## 3. UI Automator (Cross-App & System UI) + +UI Automator can interact with any visible UI — system dialogs, notifications, other apps. Use it when Espresso can't reach outside your app's process. + +| Use Case | Why UI Automator | +|------------------------------|----------------------------------------| +| Runtime permission dialogs | System UI, outside app process | +| Notification actions | System notification shade | +| Device settings interaction | Settings app | +| Multi-app workflows | e.g., share to another app and return | + +```kotlin +@RunWith(AndroidJUnit4::class) +class PermissionFlowTest { + + private lateinit var device: UiDevice + + @Before + fun setup() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + } + + @Test + fun grantsCameraPermission_andOpensCamera() { + // Trigger permission request from within your app + onView(withId(R.id.camera_button)).perform(click()) + + // Handle the system permission dialog via UI Automator + val allowButton = device.findObject( + By.res("com.android.permissioncontroller:id/permission_allow_foreground_only_button") + ) + allowButton?.click() + + // Back in Espresso territory — verify the camera view appeared + onView(withId(R.id.camera_preview)).check(matches(isDisplayed())) + } + + @Test + fun notificationTap_opensDetail() { + // Open notification shade + device.openNotification() + device.wait(Until.hasObject(By.textStartsWith("New message")), 5000) + + // Tap the notification + val notification = device.findObject(By.textStartsWith("New message")) + notification.click() + + // Verify deep-link target + onView(withId(R.id.message_detail)).check(matches(isDisplayed())) + } +} +``` + +--- + +## 4. Compose UI Testing + +Compose has its own testing framework that works with the semantic tree rather than the view hierarchy. Tests can run as local tests (with Robolectric) or instrumentation tests — the API is the same. + +### Basic Setup + +```kotlin +@RunWith(AndroidJUnit4::class) +class GreetingScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun displaysGreeting_andRespondsToClick() { + composeTestRule.setContent { + MyAppTheme { + GreetingScreen(name = "World") + } + } + + composeTestRule.onNodeWithText("Hello, World!") + .assertIsDisplayed() + + composeTestRule.onNodeWithText("Say Hi") + .performClick() + + composeTestRule.onNodeWithText("Hi back!") + .assertIsDisplayed() + } +} +``` + +### Finders, Assertions & Actions + +| Category | API | Example | +|------------|----------------------------------------------|---------------------------------| +| **Find** | `onNodeWithText("x")` | Matches visible text | +| | `onNodeWithTag("x")` | Matches `Modifier.testTag("x")` | +| | `onNodeWithContentDescription("x")` | Matches semantics label | +| | `onAllNodesWithTag("x")` | Returns list of matches | +| **Assert** | `assertIsDisplayed()` | Node is visible | +| | `assertTextEquals("x")` | Exact text match | +| | `assertIsEnabled()` / `assertIsNotEnabled()` | Enabled state | +| | `assertDoesNotExist()` | Node not in tree | +| | `assertCountEquals(n)` | For `onAllNodes` | +| **Act** | `performClick()` | Tap | +| | `performTextInput("x")` | Type into text field | +| | `performScrollTo()` | Scroll node into view | +| | `performTouchInput { swipeUp() }` | Gestures | + +### Using testTag for Reliable Selectors + +Text-based finders break with localization or copy changes. Use `testTag` for stable selectors: + +```kotlin +// ❌ Fragile: breaks if text changes or app is localized +composeTestRule.onNodeWithText("Submit Order").performClick() + +// ✅ Stable: testTag doesn't change with locale +composeTestRule.onNodeWithTag("submit_order_button").performClick() +``` + +```kotlin +// In production Composable +Button( + onClick = { /* ... */ }, + modifier = Modifier.testTag("submit_order_button") +) { + Text(stringResource(R.string.submit_order)) +} +``` + +### Testing with Activity Context + +When your Composable needs a `ComponentActivity` (e.g., for `viewModel()` or navigation), use `createAndroidComposeRule`: + +```kotlin +@get:Rule +val composeTestRule = createAndroidComposeRule() + +@Test +fun fullScreen_endToEnd() { + // Activity is already launched — interact with the real content + composeTestRule.onNodeWithTag("login_email") + .performTextInput("user@test.com") + composeTestRule.onNodeWithTag("login_password") + .performTextInput("pass123") + composeTestRule.onNodeWithTag("login_submit") + .performClick() + + composeTestRule.waitUntil(timeoutMillis = 5000) { + composeTestRule.onAllNodesWithTag("home_screen") + .fetchSemanticsNodes().isNotEmpty() + } + + composeTestRule.onNodeWithTag("home_screen") + .assertIsDisplayed() +} +``` + +### Testing Navigation + +```kotlin +@Test +fun navigatesToDetail_onItemClick() { + val navController = TestNavHostController(ApplicationProvider.getApplicationContext()) + + composeTestRule.setContent { + navController.navigatorProvider.addNavigator(ComposeNavigator()) + MyAppNavHost(navController = navController) + } + + // Click item on list screen + composeTestRule.onNodeWithTag("item_0").performClick() + + // Verify navigation destination + assertEquals("detail/0", navController.currentBackStackEntry?.destination?.route) +} +``` + +### Common Compose Test Mistakes + +```kotlin +// ❌ Wrong: Asserting immediately after async operation +composeTestRule.onNodeWithTag("submit").performClick() +composeTestRule.onNodeWithText("Success").assertIsDisplayed() // may fail — UI hasn't updated yet + +// ✅ Correct: Wait for the UI to settle +composeTestRule.onNodeWithTag("submit").performClick() +composeTestRule.waitForIdle() +composeTestRule.onNodeWithText("Success").assertIsDisplayed() + +// ✅ Also correct: waitUntil for longer async work +composeTestRule.onNodeWithTag("submit").performClick() +composeTestRule.waitUntil(timeoutMillis = 3000) { + composeTestRule.onAllNodesWithText("Success") + .fetchSemanticsNodes().isNotEmpty() +} +``` + +--- + +## 5. Gradle Managed Devices + +Define emulator profiles in `build.gradle.kts` so anyone (including CI) can run instrumentation tests without manually creating AVDs. Gradle downloads the system image, creates the emulator, runs tests, and tears it down automatically. + +### Device Configuration + +In `app/build.gradle.kts`: + +```kotlin +android { + testOptions { + managedDevices { + localDevices { + create("pixel6Api34") { + device = "Pixel 6" + apiLevel = 34 + systemImageSource = "aosp-atd" // ATD = faster, headless + } + create("pixel4Api30") { + device = "Pixel 4" + apiLevel = 30 + systemImageSource = "aosp-atd" + } + create("smallTabletApi34") { + device = "Nexus 7" + apiLevel = 34 + systemImageSource = "google" // full Google APIs image + } + } + + // Group devices for matrix testing + groups { + create("phoneTests") { + targetDevices.add(devices["pixel6Api34"]) + targetDevices.add(devices["pixel4Api30"]) + } + create("allDevices") { + targetDevices.add(devices["pixel6Api34"]) + targetDevices.add(devices["pixel4Api30"]) + targetDevices.add(devices["smallTabletApi34"]) + } + } + } + } +} +``` + +### System Image Sources + +| Source | Description | Best For | +|----------------|---------------------------------------------------|------------------------------| +| `"aosp-atd"` | Automated Test Device — minimal, no Play Services | Fast CI, pure logic tests | +| `"google-atd"` | ATD with Google APIs | Tests needing Maps, Firebase | +| `"aosp"` | Full AOSP image | Standard emulator testing | +| `"google"` | Full image with Google Play Services | Play Services integration | + +ATD images boot faster and consume less memory because they strip out UI chrome and preinstalled apps irrelevant to testing. Prefer `aosp-atd` or `google-atd` for CI pipelines. + +### Running Tests + +```bash +# Run on a single managed device +./gradlew pixel6Api34DebugAndroidTest + +# Run on a device group (all devices in parallel if hardware allows) +./gradlew phoneTestsGroupDebugAndroidTest +./gradlew allDevicesGroupDebugAndroidTest + +# With specific flavor +./gradlew pixel6Api34DevDebugAndroidTest + +# Enable test sharding across devices (speeds up large suites) +./gradlew allDevicesGroupDebugAndroidTest \ + -Pandroid.experimental.androidTest.numManagedDeviceShards=2 + +# Generate HTML test report +./gradlew pixel6Api34DebugAndroidTest \ + --continue # don't stop on first failure +``` + +Test results are written to `app/build/reports/androidTests/managedDevice/`.