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:
@@ -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
|
||||
```
|
||||
|
||||
554
skills/android-native-dev/references/testing.md
Normal file
554
skills/android-native-dev/references/testing.md
Normal 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/`.
|
||||
Reference in New Issue
Block a user