# SkillPatch skill: espresso-skill

Generates Espresso UI tests for Android apps in Kotlin or Java, covering core patterns like ViewMatchers, ViewActions, and ViewAssertions. Supports both local emulator/device execution via Gradle and cloud-based testing on platforms like TestMu AI. Includes actionable code templates and decision flows for senior-level Android QA workflows.

You (the agent) were given this URL and asked to use this skill. This is a **public** skill — no SkillPatch account, API key, or prior setup is required. Two ways to use it:

**1. Use it right now** — the skill's complete file tree (SKILL.md + all reference files) is inlined below; read `SKILL.md` first, then follow it, consulting the other files as it directs.

**2. Install the exact package onto disk** (recommended if you can run a shell — this reproduces the skill byte-for-byte, including any binary assets that can't be inlined):

```bash
mkdir -p .claude/skills/espresso-skill
curl -sSL https://skillpatch.dev/install_skill/espresso-skill | tar -xz -C .claude/skills/
```

(`.claude/skills/` is Claude Code's convention; use whatever directory your agent loads skills from.)


---

## Skill files (4)

- `SKILL.md`
- `reference/advanced-patterns.md`
- `reference/cloud-integration.md`
- `reference/playbook.md`


### `SKILL.md`

````markdown
---
name: espresso-skill
description: >
  Generates Espresso UI tests for Android apps in Kotlin or Java. Espresso runs
  inside the app process for fast, reliable UI testing. Supports local and TestMu AI
  cloud real devices. Use when user mentions "Espresso", "onView", "ViewMatchers",
  "Android UI test", or "instrumentation test". Triggers on: "Espresso",
  "onView", "ViewMatchers", "Android UI test", "instrumentation", "TestMu".
languages:
  - Java
  - Kotlin
category: mobile-testing
license: MIT
metadata:
  author: TestMu AI
  version: "1.0"
---

# Espresso Automation Skill

You are a senior Android QA engineer specializing in Espresso UI testing.

## Step 1 — Execution Target

```
├─ Mentions "cloud", "TestMu", "LambdaTest", "device farm"?
│  └─ TestMu AI cloud (upload APK + test APK)
│
├─ Mentions "emulator", "local", "connected device"?
│  └─ Local: ./gradlew connectedAndroidTest
│
└─ Default → Local emulator
```

## Core Patterns — Kotlin (Default)

### Basic Test

```kotlin
@RunWith(AndroidJUnit4::class)
class LoginTest {

    @get:Rule
    val activityRule = ActivityScenarioRule(LoginActivity::class.java)

    @Test
    fun loginWithValidCredentials() {
        // Type email
        onView(withId(R.id.emailInput))
            .perform(typeText("user@test.com"), closeSoftKeyboard())

        // Type password
        onView(withId(R.id.passwordInput))
            .perform(typeText("password123"), closeSoftKeyboard())

        // Click login button
        onView(withId(R.id.loginButton))
            .perform(click())

        // Verify dashboard is displayed
        onView(withId(R.id.dashboardTitle))
            .check(matches(isDisplayed()))
            .check(matches(withText("Welcome")))
    }

    @Test
    fun loginWithInvalidCredentials_showsError() {
        onView(withId(R.id.emailInput))
            .perform(typeText("wrong@test.com"), closeSoftKeyboard())
        onView(withId(R.id.passwordInput))
            .perform(typeText("wrong"), closeSoftKeyboard())
        onView(withId(R.id.loginButton))
            .perform(click())
        onView(withId(R.id.errorText))
            .check(matches(isDisplayed()))
            .check(matches(withText(containsString("Invalid"))))
    }
}
```

### ViewMatchers (Finding Elements)

```kotlin
// By ID (best)
onView(withId(R.id.loginButton))

// By text
onView(withText("Login"))

// By content description (accessibility)
onView(withContentDescription("Submit form"))

// By hint text
onView(withHint("Enter your email"))

// Combined matchers
onView(allOf(withId(R.id.button), withText("Submit"), isDisplayed()))

// In RecyclerView
onView(withId(R.id.recyclerView))
    .perform(RecyclerViewActions.actionOnItemAtPosition<ViewHolder>(0, click()))

// By parent
onView(allOf(withText("Delete"), isDescendantOfA(withId(R.id.toolbar))))
```

### ViewActions (Performing Actions)

```kotlin
.perform(click())                          // Tap
.perform(longClick())                      // Long press
.perform(typeText("hello"))                // Type text
.perform(replaceText("new text"))          // Replace text
.perform(clearText())                      // Clear field
.perform(closeSoftKeyboard())              // Dismiss keyboard
.perform(scrollTo())                       // Scroll to element
.perform(swipeUp())                        // Swipe gesture
.perform(swipeDown())
.perform(swipeLeft())
.perform(swipeRight())
.perform(pressBack())                      // Back button
```

### ViewAssertions (Checking State)

```kotlin
.check(matches(isDisplayed()))             // Visible
.check(matches(not(isDisplayed())))        // Not visible
.check(matches(withText("Expected")))      // Text matches
.check(matches(isEnabled()))               // Enabled
.check(matches(isChecked()))               // Checkbox checked
.check(matches(hasErrorText("Required")))  // Error text
.check(doesNotExist())                     // Not in hierarchy
```

### Idling Resources (Async Operations)

```kotlin
// Register before test
@Before
fun setUp() {
    IdlingRegistry.getInstance().register(myIdlingResource)
}

// Unregister after test
@After
fun tearDown() {
    IdlingRegistry.getInstance().unregister(myIdlingResource)
}

// Custom IdlingResource for network calls
class NetworkIdlingResource : IdlingResource {
    private var callback: IdlingResource.ResourceCallback? = null
    private var isIdle = true

    override fun getName() = "NetworkIdlingResource"
    override fun isIdleNow() = isIdle
    override fun registerIdleTransitionCallback(callback: ResourceCallback) {
        this.callback = callback
    }

    fun setIdle(idle: Boolean) {
        isIdle = idle
        if (idle) callback?.onTransitionToIdle()
    }
}
```

### Anti-Patterns

| Bad | Good | Why |
|-----|------|-----|
| `Thread.sleep()` | IdlingResources | Espresso auto-syncs UI thread |
| XPath-like traversal | `withId(R.id.x)` | Direct ID is fastest |
| Testing across activities | Test single screen, mock data | Isolation |
| No `closeSoftKeyboard()` | Always close after `typeText()` | Keyboard blocks elements |

### TestMu AI Cloud

```bash
# 1. Build APK and test APK
./gradlew assembleDebug assembleDebugAndroidTest

# 2. Upload both to LambdaTest
curl -u "$LT_USERNAME:$LT_ACCESS_KEY" \
  -X POST "https://manual-api.lambdatest.com/app/upload/realDevice" \
  -F "appFile=@app/build/outputs/apk/debug/app-debug.apk" \
  -F "type=android"

curl -u "$LT_USERNAME:$LT_ACCESS_KEY" \
  -X POST "https://manual-api.lambdatest.com/app/upload/realDevice" \
  -F "appFile=@app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk" \
  -F "type=android"

# 3. Execute on real devices via API
curl -u "$LT_USERNAME:$LT_ACCESS_KEY" \
  -X POST "https://mobile-api.lambdatest.com/framework/v1/espresso/build" \
  -H "Content-Type: application/json" \
  -d '{
    "app": "lt://APP123",
    "testSuite": "lt://TEST456",
    "device": ["Pixel 8-14", "Galaxy S24-14"],
    "build": "Espresso Cloud Build",
    "video": true, "deviceLog": true
  }'
```

## build.gradle Setup

```groovy
android {
    defaultConfig {
        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }
}

dependencies {
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
    androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.5.1'
    androidTestImplementation 'androidx.test.espresso:espresso-intents:3.5.1'
    androidTestImplementation 'androidx.test:runner:1.5.2'
    androidTestImplementation 'androidx.test:rules:1.5.0'
    androidTestImplementation 'androidx.test.ext:junit:1.1.5'
}
```

## Quick Reference

| Task | Command/Code |
|------|-------------|
| Run all tests | `./gradlew connectedAndroidTest` |
| Run specific class | `./gradlew connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.example.LoginTest` |
| Run on specific device | `./gradlew connectedAndroidTest -PtestDevice=emulator-5554` |
| Intent verification | `Intents.init()` → `intended(hasComponent(...))` → `Intents.release()` |
| RecyclerView scroll | `RecyclerViewActions.scrollToPosition<>(10)` |
| Screenshot | `Screenshot.capture(activityRule.activity)` |

## Reference Files

| File | When to Read |
|------|-------------|
| `reference/cloud-integration.md` | LambdaTest Espresso, device farm, API |
| `reference/advanced-patterns.md` | Intents, RecyclerView, custom matchers |

## Deep Patterns → `reference/playbook.md`

| § | Section | Lines |
|---|---------|-------|
| 1 | Project Setup | Gradle deps, Orchestrator |
| 2 | Test Structure & Lifecycle | Rules, permissions, annotations |
| 3 | Custom Matchers & ViewActions | RecyclerView, wait, scroll |
| 4 | RecyclerView Testing | Scroll, click child, swipe, assert |
| 5 | Idling Resources | Counting, OkHttp, custom |
| 6 | Intent Testing | Share, stub, camera |
| 7 | MockWebServer for API Tests | Enqueue, error handling |
| 8 | CI/CD Integration | GitHub Actions, emulator runner |
| 9 | Debugging Quick-Reference | 10 common problems |
| 10 | Best Practices Checklist | 13 items |

````


### `reference/advanced-patterns.md`

````markdown
# Espresso — Advanced Patterns

## Intent Testing

```kotlin
@RunWith(AndroidJUnit4::class)
class IntentTest {
    @get:Rule
    val intentsRule = IntentsTestRule(MainActivity::class.java)

    @Test
    fun clickShare_launchesChooser() {
        onView(withId(R.id.shareButton)).perform(click())
        intended(hasAction(Intent.ACTION_CHOOSER))
    }

    @Test
    fun clickLink_opensExternalBrowser() {
        intending(hasAction(Intent.ACTION_VIEW))
            .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
        onView(withId(R.id.externalLink)).perform(click())
        intended(allOf(hasAction(Intent.ACTION_VIEW), hasData("https://example.com")))
    }
}
```

## RecyclerView Testing

```kotlin
// Click item at position
onView(withId(R.id.recyclerView))
    .perform(RecyclerViewActions.actionOnItemAtPosition<ViewHolder>(3, click()))

// Scroll to item with text
onView(withId(R.id.recyclerView))
    .perform(RecyclerViewActions.scrollTo<ViewHolder>(
        hasDescendant(withText("Item 42"))
    ))

// Check item at position
onView(withId(R.id.recyclerView))
    .check(matches(atPosition(0, hasDescendant(withText("First Item")))))
```

## Custom ViewMatcher

```kotlin
fun withItemCount(expectedCount: Int): Matcher<View> {
    return object : BoundedMatcher<View, RecyclerView>(RecyclerView::class.java) {
        override fun describeTo(description: Description) {
            description.appendText("RecyclerView with item count: $expectedCount")
        }
        override fun matchesSafely(view: RecyclerView): Boolean {
            return view.adapter?.itemCount == expectedCount
        }
    }
}

// Usage
onView(withId(R.id.recyclerView)).check(matches(withItemCount(10)))
```

````


### `reference/cloud-integration.md`

````markdown
# Espresso — TestMu AI Cloud Integration

For full device catalog, capabilities, and LT:Options reference, see [shared/testmu-cloud-reference.md](../../shared/testmu-cloud-reference.md).

## Upload APKs

```bash
# App APK
curl -u "$LT_USERNAME:$LT_ACCESS_KEY" \
  -X POST "https://manual-api.lambdatest.com/app/upload/realDevice" \
  -F "appFile=@app-debug.apk" -F "type=android"
# Returns: { "app_url": "lt://APP123" }

# Test APK
curl -u "$LT_USERNAME:$LT_ACCESS_KEY" \
  -X POST "https://manual-api.lambdatest.com/app/upload/realDevice" \
  -F "appFile=@app-debug-androidTest.apk" -F "type=android"
# Returns: { "app_url": "lt://TEST456" }
```

## Execute Tests

```bash
curl -u "$LT_USERNAME:$LT_ACCESS_KEY" \
  -X POST "https://mobile-api.lambdatest.com/framework/v1/espresso/build" \
  -H "Content-Type: application/json" \
  -d '{
    "app": "lt://APP123",
    "testSuite": "lt://TEST456",
    "device": ["Pixel 8-14", "Pixel 7-13", "Galaxy S24-14"],
    "build": "Espresso Cloud",
    "video": true,
    "deviceLog": true,
    "queueTimeout": 600,
    "idleTimeout": 150
  }'
```

## Check Build Status

```bash
curl -u "$LT_USERNAME:$LT_ACCESS_KEY" \
  "https://mobile-api.lambdatest.com/framework/v1/espresso/build/<build_id>"
```

````


### `reference/playbook.md`

````markdown
# Espresso — Advanced Implementation Playbook

## §1 — Project Setup

```gradle
// build.gradle (app)
android {
    defaultConfig {
        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
        testInstrumentationRunnerArguments clearPackageData: "true"
    }
    testOptions {
        animationsDisabled = true
        execution "ANDROIDX_TEST_ORCHESTRATOR"
    }
}

dependencies {
    androidTestImplementation "androidx.test.espresso:espresso-core:3.5.1"
    androidTestImplementation "androidx.test.espresso:espresso-contrib:3.5.1"
    androidTestImplementation "androidx.test.espresso:espresso-intents:3.5.1"
    androidTestImplementation "androidx.test.espresso:espresso-idling-resource:3.5.1"
    androidTestImplementation "androidx.test.ext:junit:1.1.5"
    androidTestImplementation "androidx.test:runner:1.5.2"
    androidTestImplementation "androidx.test:rules:1.5.0"
    androidTestUtil "androidx.test:orchestrator:1.4.2"
    androidTestImplementation "com.jakewharton.espresso:okhttp3-idling-resource:1.0.0"
    androidTestImplementation "org.mockito:mockito-android:5.10.0"
    androidTestImplementation "io.mockk:mockk-android:1.13.10"
    androidTestImplementation "com.squareup.okhttp3:mockwebserver:4.12.0"
}
```

## §2 — Test Structure & Lifecycle

```kotlin
@RunWith(AndroidJUnit4::class)
@LargeTest
class LoginTest {
    @get:Rule
    val activityRule = ActivityScenarioRule(LoginActivity::class.java)

    @get:Rule
    val grantPermissionRule = GrantPermissionRule.grant(
        android.Manifest.permission.CAMERA,
        android.Manifest.permission.ACCESS_FINE_LOCATION
    )

    @Before
    fun setup() {
        IdlingRegistry.getInstance().register(EspressoIdlingResource.countingIdlingResource)
    }

    @After
    fun teardown() {
        IdlingRegistry.getInstance().unregister(EspressoIdlingResource.countingIdlingResource)
    }

    @Test
    fun successfulLogin() {
        onView(withId(R.id.emailInput))
            .perform(typeText("user@test.com"), closeSoftKeyboard())
        onView(withId(R.id.passwordInput))
            .perform(typeText("password123"), closeSoftKeyboard())
        onView(withId(R.id.loginBtn)).perform(click())
        onView(withId(R.id.welcomeText))
            .check(matches(withText(containsString("Welcome"))))
    }

    @Test
    fun showsErrorOnInvalidEmail() {
        onView(withId(R.id.emailInput))
            .perform(typeText("invalid"), closeSoftKeyboard())
        onView(withId(R.id.loginBtn)).perform(click())
        onView(withId(R.id.emailError))
            .check(matches(withText("Invalid email format")))
    }

    @Test
    fun emptyFieldsShowValidation() {
        onView(withId(R.id.loginBtn)).perform(click())
        onView(withId(R.id.emailError))
            .check(matches(isDisplayed()))
        onView(withId(R.id.passwordError))
            .check(matches(isDisplayed()))
    }
}
```

## §3 — Custom Matchers & ViewActions

```kotlin
// Custom matcher: RecyclerView item count
fun hasItemCount(count: Int): Matcher<View> =
    object : BoundedMatcher<View, RecyclerView>(RecyclerView::class.java) {
        override fun describeTo(desc: Description) { desc.appendText("has $count items") }
        override fun matchesSafely(rv: RecyclerView) = rv.adapter?.itemCount == count
    }

// Custom matcher: EditText error text
fun hasErrorText(expected: String): Matcher<View> =
    object : BoundedMatcher<View, EditText>(EditText::class.java) {
        override fun describeTo(desc: Description) { desc.appendText("has error: $expected") }
        override fun matchesSafely(item: EditText) = item.error?.toString() == expected
    }

// Custom matcher: view at specific position in RecyclerView
fun atPosition(position: Int, matcher: Matcher<View>): Matcher<View> =
    object : BoundedMatcher<View, RecyclerView>(RecyclerView::class.java) {
        override fun describeTo(desc: Description) {
            desc.appendText("has item at position $position matching: ")
            matcher.describeTo(desc)
        }
        override fun matchesSafely(rv: RecyclerView): Boolean {
            val vh = rv.findViewHolderForAdapterPosition(position) ?: return false
            return matcher.matches(vh.itemView)
        }
    }

// Wait for view (idling-safe alternative)
fun waitForView(viewMatcher: Matcher<View>, timeout: Long = 5000): ViewInteraction {
    val end = System.currentTimeMillis() + timeout
    while (System.currentTimeMillis() < end) {
        try {
            return onView(viewMatcher).check(matches(isDisplayed()))
        } catch (e: Exception) { Thread.sleep(100) }
    }
    throw AssertionError("View not found within ${timeout}ms")
}

// Custom scroll action for NestedScrollView
fun nestedScrollTo(): ViewAction = object : ViewAction {
    override fun getConstraints() = allOf(isDescendantOfA(isAssignableFrom(NestedScrollView::class.java)))
    override fun getDescription() = "nested scroll to"
    override fun perform(uiController: UiController, view: View) {
        view.requestRectangleOnScreen(Rect(0, 0, view.width, view.height), true)
        uiController.loopMainThreadUntilIdle()
    }
}
```

## §4 — RecyclerView Testing

```kotlin
// Scroll and click
onView(withId(R.id.recyclerView))
    .perform(RecyclerViewActions.scrollToPosition<ViewHolder>(15))
    .perform(RecyclerViewActions.actionOnItemAtPosition<ViewHolder>(15, click()))

// Click on specific view inside item
onView(withId(R.id.recyclerView))
    .perform(RecyclerViewActions.actionOnItemAtPosition<ViewHolder>(3,
        clickOnViewChild(R.id.deleteBtn)))

fun clickOnViewChild(viewId: Int): ViewAction = object : ViewAction {
    override fun getConstraints() = null
    override fun getDescription() = "Click on child view"
    override fun perform(uiController: UiController, view: View) {
        view.findViewById<View>(viewId).performClick()
    }
}

// Assert item content
onView(withId(R.id.recyclerView))
    .check(matches(atPosition(0,
        hasDescendant(withText("First Item")))))

// Assert total count
onView(withId(R.id.recyclerView)).check(matches(hasItemCount(10)))

// Swipe to dismiss
onView(withId(R.id.recyclerView))
    .perform(RecyclerViewActions.actionOnItemAtPosition<ViewHolder>(2,
        swipeLeft()))
```

## §5 — Idling Resources

```kotlin
// CountingIdlingResource (most common)
object EspressoIdlingResource {
    private const val RESOURCE = "GLOBAL"
    @JvmField val countingIdlingResource = CountingIdlingResource(RESOURCE)

    fun increment() = countingIdlingResource.increment()
    fun decrement() {
        if (!countingIdlingResource.isIdleNow) countingIdlingResource.decrement()
    }
}

// Usage in production code
class UserRepository(private val api: UserApi) {
    suspend fun getUsers(): List<User> {
        EspressoIdlingResource.increment()
        try {
            return api.getUsers()
        } finally {
            EspressoIdlingResource.decrement()
        }
    }
}

// OkHttp IdlingResource
val client = OkHttpClient.Builder().build()
val idlingResource = OkHttp3IdlingResource.create("OkHttp", client)

@Before fun register() { IdlingRegistry.getInstance().register(idlingResource) }
@After fun unregister() { IdlingRegistry.getInstance().unregister(idlingResource) }

// Custom IdlingResource for animations
class ViewAnimationIdlingResource(private val view: View) : IdlingResource {
    private var callback: IdlingResource.ResourceCallback? = null
    override fun getName() = "ViewAnimation:${view.id}"
    override fun isIdleNow(): Boolean {
        val idle = !view.isAnimating()
        if (idle) callback?.onTransitionToIdle()
        return idle
    }
    override fun registerIdleTransitionCallback(cb: IdlingResource.ResourceCallback) { callback = cb }
}
```

## §6 — Intent Testing

```kotlin
@RunWith(AndroidJUnit4::class)
class IntentTest {
    @get:Rule val intentsRule = IntentsTestRule(MainActivity::class.java)

    @Test
    fun opensShareIntent() {
        onView(withId(R.id.shareBtn)).perform(click())

        intended(allOf(
            hasAction(Intent.ACTION_SEND),
            hasType("text/plain"),
            hasExtra(Intent.EXTRA_TEXT, containsString("Check this out"))
        ))
    }

    @Test
    fun stubsExternalActivity() {
        // Stub external intent response
        intending(hasAction(Intent.ACTION_VIEW))
            .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))

        onView(withId(R.id.externalLink)).perform(click())

        intended(hasData(Uri.parse("https://example.com")))
    }

    @Test
    fun stubsCameraIntent() {
        val resultData = Intent().apply {
            putExtra("data", createTestBitmap())
        }
        intending(hasAction(MediaStore.ACTION_IMAGE_CAPTURE))
            .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, resultData))

        onView(withId(R.id.cameraBtn)).perform(click())
        onView(withId(R.id.previewImage)).check(matches(isDisplayed()))
    }
}
```

## §7 — MockWebServer for API Tests

```kotlin
@RunWith(AndroidJUnit4::class)
class ApiIntegrationTest {
    private lateinit var mockServer: MockWebServer

    @Before
    fun setup() {
        mockServer = MockWebServer()
        mockServer.start(8080)
        // Configure app to use mock server URL
    }

    @After
    fun teardown() { mockServer.shutdown() }

    @Test
    fun displaysUsersFromApi() {
        mockServer.enqueue(MockResponse()
            .setBody("""[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]""")
            .addHeader("Content-Type", "application/json"))

        val scenario = ActivityScenario.launch(UserListActivity::class.java)

        onView(withId(R.id.recyclerView))
            .check(matches(hasItemCount(2)))
        onView(withId(R.id.recyclerView))
            .check(matches(atPosition(0, hasDescendant(withText("Alice")))))
    }

    @Test
    fun handlesServerError() {
        mockServer.enqueue(MockResponse().setResponseCode(500))

        val scenario = ActivityScenario.launch(UserListActivity::class.java)

        onView(withId(R.id.errorView)).check(matches(isDisplayed()))
        onView(withId(R.id.retryBtn)).check(matches(isDisplayed()))
    }
}
```

## §8 — CI/CD Integration

```yaml
# GitHub Actions
name: Espresso Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: 17 }
      - name: Run Espresso tests
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 34
          target: google_apis
          arch: x86_64
          disable-animations: true
          script: ./gradlew connectedAndroidTest
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: espresso-results
          path: app/build/reports/androidTests/
```

## §9 — Debugging Quick-Reference

| Problem | Cause | Fix |
|---------|-------|-----|
| `NoMatchingViewException` | View not in hierarchy | Check `onView(isRoot()).perform(closeSoftKeyboard())`, scroll to view |
| `AmbiguousViewMatcherException` | Multiple matching views | Add more matchers: `allOf(withId(...), isDisplayed())` |
| `PerformException` on click | View not clickable/visible | Use `scrollTo()` or `nestedScrollTo()` before click |
| Test hangs | No IdlingResource for async | Register `CountingIdlingResource` or `OkHttp3IdlingResource` |
| Animations cause flakiness | System animations enabled | Set `animationsDisabled = true` in `testOptions` |
| `NoActivityResumedError` | Activity finished or crashed | Check `ActivityScenarioRule` setup, verify activity launches |
| RecyclerView not found | Not scrolled into view | Use `RecyclerViewActions.scrollToPosition()` first |
| Intent not captured | `Intents.init()` not called | Use `IntentsTestRule` or call `Intents.init()` in `@Before` |
| Keyboard overlaps view | Soft keyboard blocking | Call `closeSoftKeyboard()` after `typeText()` |
| Flaky on CI | Timing issues | Use Orchestrator, disable animations, increase timeout |

## §10 — Best Practices Checklist

- ✅ Use `IdlingResource` instead of `Thread.sleep()` — always
- ✅ Use `withId()` over `withText()` for selector stability
- ✅ Always call `closeSoftKeyboard()` after `typeText()`
- ✅ Use `ActivityScenarioRule` for lifecycle management
- ✅ Set `animationsDisabled = true` in Gradle test options
- ✅ Use Orchestrator for isolated test execution
- ✅ Use `MockWebServer` for API response testing
- ✅ Use `@LargeTest` / `@MediumTest` / `@SmallTest` annotations
- ✅ Use custom matchers for RecyclerView assertions
- ✅ Release `Intents` in `@After` to prevent leaks
- ✅ Use `GrantPermissionRule` for runtime permissions
- ✅ Register/unregister IdlingResources in `@Before`/`@After`
- ✅ Structure: `androidTest/tests/`, `androidTest/robots/`, `androidTest/utils/`

````
