Merge pull request #3 from utzcoz/add-testing-for-android-native-dev-skill

fix(android-native-dev): Add testing for android-native-dev skill
This commit is contained in:
zest0198
2026-03-26 11:31:50 +08:00
committed by GitHub
2 changed files with 655 additions and 0 deletions

View File

@@ -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:<version>") // 4.13.2+
testImplementation("org.robolectric:robolectric:<version>") // 4.16.1+
testImplementation("io.mockk:mockk:<version>") // match Kotlin version
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:<version>") // match coroutines-core
testImplementation("androidx.arch.core:core-testing:<version>") // InstantTaskExecutorRule for LiveData
testImplementation("app.cash.turbine:turbine:<version>") // Flow/StateFlow testing
// --- Instrumentation tests (src/androidTest/) ---
androidTestImplementation("androidx.test.ext:junit:<version>")
androidTestImplementation("androidx.test:runner:<version>")
androidTestImplementation("androidx.test:rules:<version>")
androidTestImplementation("androidx.test.espresso:espresso-core:<version>")
androidTestImplementation("androidx.test.espresso:espresso-contrib:<version>") // RecyclerView, Drawer
androidTestImplementation("androidx.test.espresso:espresso-intents:<version>") // Intent verification
androidTestImplementation("androidx.test.espresso:espresso-idling-resource:<version>")
androidTestImplementation("androidx.test.uiautomator:uiautomator:<version>")
// --- 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
```

View File

@@ -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<UserApi>()
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<T>()` | Create mock instance |
| `every { }` | Stub synchronous calls |
| `coEvery { }` | Stub suspend functions |
| `verify { }` | Verify call happened |
| `coVerify { }` | Verify suspend call |
| `slot<T>()` | 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<MainActivity>()
@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/`.