Beginner-to-advanced notes on local and instrumented Android tests, Robolectric, ActivityScenario, FragmentScenario, Espresso, RecyclerView testing, lifecycle testing, synchronization and custom IdlingResources.

1. Local Tests vs Instrumented Tests

Local tests

Local tests run on the computer’s JVM without a device.

Best suited for:

* Kotlin functions

* Validators and mappers

* Use cases

* ViewModels

* Coroutines and Flow

* Repository logic with fakes

* Robolectric tests

Characteristics:

Fast + isolated + inexpensive

Instrumented tests

Instrumented tests run on an Android device or emulator.

Best suited for:

* Espresso tests

* Real Android framework behaviour

* Activities and Fragments

* Room and SQLite integration

* Permissions

* Navigation

* Device APIs

* Complete user journeys

Characteristics:

Slower + realistic + higher maintenance

Comparison

PointLocalInstrumented
EnvironmentJVMDevice/emulator
SpeedFastSlower
Android behaviourMocked/simulatedReal
Common toolsJUnit, RobolectricEspresso, AndroidX Test
ReliabilityUsually higherEnvironment-dependent
Framework confidenceLowerHigher

---

2. src/test vs src/androidTest

app/
└── src/
    ├── main/
    │   └── java/
    ├── test/
    │   └── java/
    │       └── LoginViewModelTest.kt
    └── androidTest/
        └── java/
            └── LoginActivityTest.kt
DirectoryContainsRuns on
src/mainProduction codeApplication
src/testLocal/Robolectric testsJVM
src/androidTestInstrumented testsDevice/emulator
dependencies {
    testImplementation(libs.junit)
    testImplementation(libs.robolectric)

    androidTestImplementation(libs.androidx.test.runner)
    androidTestImplementation(libs.espresso.core)
    androidTestImplementation(libs.espresso.contrib)
}

---

3. AndroidJUnitRunner

AndroidJUnitRunner is the standard runner for instrumented JUnit 4 Android tests.

It supports:

* Espresso

* UI Automator

* Compose testing

* AndroidX Test

* Test filtering

* Device-side test execution

Configuration

android {
    defaultConfig {
        testInstrumentationRunner =
            "androidx.test.runner.AndroidJUnitRunner"
    }
}

Test

@RunWith(AndroidJUnit4::class)
class LoginActivityTest {

    @Test
    fun loginScreen_isDisplayed() {
        onView(withId(R.id.loginButton))
            .check(matches(isDisplayed()))
    }
}

AndroidJUnitRunner is not required for ordinary local JVM tests.

---

4. Device and Emulator Tests

Emulator

Advantages:

* Easy API-level selection

* Multiple screen sizes

* Easy reset and snapshots

* Good CI support

* Reproducible configuration

Limitations:

* Not real hardware

* Performance differs from physical devices

* Some sensors are simulated

* OEM-specific issues may be missing

Physical device

Advantages:

* Real hardware

* Real performance

* Manufacturer-specific behaviour

* Real Bluetooth, NFC, camera and sensors

Limitations:

* Harder to automate

* Expensive to maintain

* Device state can differ

* Limited availability in CI

Recommended approach

Use emulators for most automated regression tests and physical devices for:

* Release validation

* Performance

* Camera/Bluetooth/NFC

* OEM-specific behaviour

* Hardware-backed security

---

5. Framework-Selection Strategy

flowchart TD
    A{"Needs Android framework?"}
    J["JUnit JVM test"]
    B{"Is simulation sufficient?"}
    R["Robolectric"]
    C{"Testing View UI?"}
    E["Espresso"]
    I["Instrumented AndroidX test"]

    A -->|No| J
    A -->|Yes| B
    B -->|Yes| R
    B -->|No| C
    C -->|Yes| E
    C -->|No| I
RequirementFramework
Business logicJUnit
ViewModel stateJUnit + coroutine test
Android resourcesRobolectric
Activity lifecycle locallyActivityScenario + Robolectric
Fragment locallyFragmentScenario + Robolectric
XML/View UIEspresso
Real database/frameworkInstrumented test
Cross-app interactionUI Automator
PerformanceBenchmark on physical device

Use the fastest test layer that provides sufficient confidence.

---

6. Robolectric Fundamentals

Robolectric simulates Android framework behaviour inside the JVM.

It supports many Android components:

* Activity

* Fragment

* Context

* Resources

* Intent

* Views

* SharedPreferences

* Services

* Lifecycle

flowchart LR
    T["JUnit test"]
    R["Robolectric"]
    S["Android shadows"]
    JVM["Local JVM"]
    O["Fast result"]

    T --> R --> S --> JVM --> O

Robolectric uses shadows, which provide JVM-side implementations of Android classes.

---

7. Simulated Android Environment

Robolectric does not start a real Android OS. It simulates framework behaviour.

Resource example

class GreetingFormatter(
    private val context: Context
) {
    fun format(name: String): String {
        return context.getString(
            R.string.greeting,
            name
        )
    }
}
@RunWith(RobolectricTestRunner::class)
class GreetingFormatterTest {

    @Test
    fun format_usesAndroidResource() {
        val context =
            ApplicationProvider
                .getApplicationContext<Context>()

        val formatter = GreetingFormatter(context)

        assertEquals(
            "Hello Mahesh",
            formatter.format("Mahesh")
        )
    }
}

This avoids mocking Context.getString().

---

8. Robolectric Test Setup

Gradle configuration

android {
    testOptions {
        unitTests {
            isIncludeAndroidResources = true
        }
    }
}
dependencies {
    testImplementation(libs.junit)
    testImplementation(libs.robolectric)
    testImplementation(libs.androidx.test.core)
}

Basic test

@RunWith(RobolectricTestRunner::class)
class MainActivityTest {

    @Test
    fun activityLaunch_displaysTitle() {
        ActivityScenario
            .launch(MainActivity::class.java)
            .use {
                onView(withText("Task Manager"))
                    .check(matches(isDisplayed()))
            }
    }
}

Test particular API level

@Config(sdk = [35])
class MainActivityTest

Test selected compatibility boundaries rather than every API level.

---

9. Testing Activity with Robolectric

Use ActivityScenario in Robolectric tests.

@RunWith(RobolectricTestRunner::class)
class LoginActivityTest {

    @Test
    fun launch_displaysLoginForm() {
        ActivityScenario
            .launch(LoginActivity::class.java)
            .use { scenario ->

                scenario.onActivity { activity ->
                    assertFalse(activity.isFinishing)
                }

                onView(withId(R.id.emailInput))
                    .check(matches(isDisplayed()))

                onView(withId(R.id.loginButton))
                    .check(matches(isDisplayed()))
            }
    }
}

Test:

* Initial UI

* Intent extras

* Resources

* Button behaviour

* Lifecycle effects

* Recreation

* Navigation intents

Test observable behaviour instead of private Activity fields.

---

10. Testing Fragment with Robolectric

Use FragmentScenario with AndroidX Fragments.

@RunWith(RobolectricTestRunner::class)
class TaskFragmentTest {

    @Test
    fun launch_displaysTaskList() {
        launchFragmentInContainer<TaskFragment>(
            themeResId = R.style.Theme_App
        ).use {
            onView(withId(R.id.taskList))
                .check(matches(isDisplayed()))
        }
    }
}

Passing arguments

val args = bundleOf(
    "category_id" to 10L
)

launchFragmentInContainer<TaskFragment>(
    fragmentArgs = args,
    themeResId = R.style.Theme_App
)

Accessing Fragment

scenario.onFragment { fragment ->
    assertEquals(
        10L,
        fragment.requireArguments()
            .getLong("category_id")
    )
}

Use a custom FragmentFactory when constructor injection is required.

---

11. Robolectric Limitations

Robolectric does not perfectly reproduce:

* OEM-specific Android behaviour

* Real hardware

* GPU rendering

* Bluetooth/NFC/camera

* Sensors

* Native code

* Performance

* Battery behaviour

* Every system service

* Exact Android framework timing

Do not use Robolectric for:

* Startup-performance measurement

* Frame-rendering measurement

* Hardware integration

* Manufacturer-specific validation

* Real-device compatibility certification

Keep selected instrumented tests for critical framework behaviour.

---

12. ActivityScenario

ActivityScenario launches and controls an Activity.

Important APIs:

ActivityScenario.launch<MyActivity>()
scenario.onActivity { }
scenario.moveToState(state)
scenario.recreate()
scenario.close()

Example

@Test
fun activityLaunchesSuccessfully() {
    ActivityScenario
        .launch(MainActivity::class.java)
        .use { scenario ->

            scenario.onActivity { activity ->
                assertNotNull(activity)
                assertFalse(activity.isFinishing)
            }
        }
}
Always close ActivityScenario using `.use {}` or `close()`.

---

13. Activity Lifecycle Testing

ActivityScenario can move an Activity between lifecycle states.

@Test
fun activityStopped_releasesCamera() {
    ActivityScenario
        .launch(CameraActivity::class.java)
        .use { scenario ->

            scenario.moveToState(
                Lifecycle.State.CREATED
            )

            scenario.onActivity { activity ->
                assertFalse(
                    activity.cameraController.isActive
                )
            }
        }
}
StateActivity condition
CREATEDCreated/stopped
STARTEDVisible, not interactive
RESUMEDForeground and interactive
DESTROYEDDestroyed

Test lifecycle effects:

* Resource acquisition/release

* Observer registration

* Playback pause/resume

* State preservation

* Background behaviour

---

14. Activity Recreation Testing

recreate() simulates Activity recreation, such as configuration change.

@Test
fun recreation_preservesEnteredName() {
    ActivityScenario
        .launch(EditProfileActivity::class.java)
        .use { scenario ->

            onView(withId(R.id.nameInput))
                .perform(
                    replaceText("Mahesh"),
                    closeSoftKeyboard()
                )

            scenario.recreate()

            onView(withId(R.id.nameInput))
                .check(
                    matches(withText("Mahesh"))
                )
        }
}

Test preservation of:

* Form input

* Selected tab

* Current filter

* Scroll position

* Dialog state

* Selected item ID

Activity recreation is not the same as process death. ViewModels survive configuration recreation but not process death.

---

15. FragmentScenario

FragmentScenario launches and controls an AndroidX Fragment.

val scenario =
    launchFragmentInContainer<TaskFragment>(
        themeResId = R.style.Theme_App
    )

Lifecycle

scenario.moveToState(
    Lifecycle.State.STARTED
)

Recreation

scenario.recreate()

Access

scenario.onFragment { fragment ->
    assertTrue(fragment.isAdded)
}

Limitations:

* Uses a test host Activity

* Production Activity integration may differ

* Navigation integration may need a broader test

* Dependency injection may require a FragmentFactory

---

16. Espresso Fundamentals

Espresso tests Android View-based interfaces.

Basic pattern:

Find View → Perform Action → Verify Result
@Test
fun login_validInput_opensHome() {
    onView(withId(R.id.emailInput))
        .perform(
            typeText("user@example.com"),
            closeSoftKeyboard()
        )

    onView(withId(R.id.passwordInput))
        .perform(
            typeText("Password123"),
            closeSoftKeyboard()
        )

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

    onView(withText("Welcome"))
        .check(matches(isDisplayed()))
}

Core Espresso functions:

onView()
perform()
check()

---

17. View Matchers

Matchers locate Views.

| Matcher                    | Purpose                         |
| -------------------------- | ------------------------------- |
| `withId()`                 | Match resource ID               |
| `withText()`               | Match text                      |
| `withHint()`               | Match input hint                |
| `withContentDescription()` | Match accessibility description |
| `isDisplayed()`            | Match displayed View            |
| `isEnabled()`              | Match enabled View              |
| `isChecked()`              | Match checked control           |
| `hasDescendant()`          | Match parent containing a child |
| `isAssignableFrom()`       | Match View class                |

Examples

onView(withId(R.id.loginButton))
onView(
    allOf(
        withText("Login"),
        isDisplayed(),
        isEnabled()
    )
)

Resolve ambiguous views

onView(
    allOf(
        withText("Save"),
        withParent(withId(R.id.toolbar))
    )
)

Prefer stable IDs and accessibility descriptions.

---

18. View Actions

Actions simulate user interactions.

| Action                   | Purpose         |
| ------------------------ | --------------- |
| `click()`                | Click           |
| `longClick()`            | Long press      |
| `typeText()`             | Type text       |
| `replaceText()`          | Replace input   |
| `clearText()`            | Clear input     |
| `closeSoftKeyboard()`    | Close keyboard  |
| `scrollTo()`             | Scroll to View  |
| `swipeUp()`              | Swipe up        |
| `swipeDown()`            | Swipe down      |
| `pressImeActionButton()` | Keyboard action |
onView(withId(R.id.emailInput))
    .perform(
        replaceText("user@example.com"),
        closeSoftKeyboard()
    )

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

replaceText() is often more deterministic than typing character by character.

---

19. View Assertions

Assertions verify UI state.

onView(withId(R.id.errorText))
    .check(
        matches(
            withText("Invalid password")
        )
    )
onView(withId(R.id.loginButton))
    .check(
        matches(isNotEnabled())
    )
onView(withText("Deleted task"))
    .check(doesNotExist())

Common assertions:

* `matches(isDisplayed())`
* `matches(withText())`
* `matches(isEnabled())`
* `matches(isChecked())`
* `matches(hasErrorText())`
* `doesNotExist()`

Important distinction:

doesNotExist()

means the View is absent.

not(isDisplayed())

means the View exists but is not visible.

---

20. RecyclerView Testing

Add Espresso Contrib:

androidTestImplementation(
    "androidx.test.espresso:espresso-contrib:<version>"
)

Scroll to position

onView(withId(R.id.taskList))
    .perform(
        RecyclerViewActions
            .scrollToPosition<TaskViewHolder>(20)
    )

Click position

onView(withId(R.id.taskList))
    .perform(
        RecyclerViewActions
            .actionOnItemAtPosition<TaskViewHolder>(
                5,
                click()
            )
    )

Match item by text

onView(withId(R.id.taskList))
    .perform(
        RecyclerViewActions.scrollTo<TaskViewHolder>(
            hasDescendant(
                withText("Learn Espresso")
            )
        )
    )

Click a child View

fun clickChild(id: Int): ViewAction {
    return object : ViewAction {

        override fun getConstraints() =
            isAssignableFrom(View::class.java)

        override fun getDescription() =
            "Click child View with ID $id"

        override fun perform(
            uiController: UiController,
            view: View
        ) {
            view.findViewById<View>(id).performClick()
        }
    }
}
onView(withId(R.id.taskList))
    .perform(
        RecyclerViewActions.actionOnItem<TaskViewHolder>(
            hasDescendant(withText("Learn Espresso")),
            clickChild(R.id.deleteButton)
        )
    )

Prefer item content over fixed positions when list order can change.

---

21. Dialog and Toast Testing

Dialog test

@Test
fun deleteClicked_displaysConfirmationDialog() {
    onView(withId(R.id.deleteButton))
        .perform(click())

    onView(withText("Delete task?"))
        .inRoot(isDialog())
        .check(matches(isDisplayed()))

    onView(withText("Delete"))
        .inRoot(isDialog())
        .perform(click())
}

Test:

* Title and message

* Confirm action

* Cancel action

* Dismissal

* Result after confirmation

Toast test with Robolectric

@Test
fun saveClicked_showsSuccessToast() {
    ActivityScenario
        .launch(EditTaskActivity::class.java)
        .use {
            onView(withId(R.id.saveButton))
                .perform(click())

            assertEquals(
                "Task saved",
                ShadowToast.getTextOfLatestToast()
            )
        }
}

Toasts are temporary and system-managed. Prefer a Snackbar or visible UI message when the user must act on the information.

---

22. Espresso Synchronization

Espresso automatically waits for:

* Main thread to become idle

* Message queue work

* View rendering

* Registered IdlingResources

sequenceDiagram
    participant Test
    participant Espresso
    participant App

    Test->>Espresso: Click
    Espresso->>App: Perform click
    App->>App: Update UI
    Espresso->>Espresso: Wait for idle
    Test->>Espresso: Verify result

Espresso may not automatically know about:

* Custom executors

* Background services

* Network callbacks

* Third-party threading

* Long-running custom operations

Do not use:

Thread.sleep(2_000)

Use a fake dependency or IdlingResource.

---

23. Idling Resources

An IdlingResource tells Espresso whether asynchronous work is busy or idle.

stateDiagram-v2
    [*] --> Idle
    Idle --> Busy: Work starts
    Busy --> Idle: Work completes
    Idle --> Continue: Espresso proceeds

CountingIdlingResource

val idlingResource =
    CountingIdlingResource("TaskLoader")

Production/test-support code:

fun loadTasks() {
    idlingResource.increment()

    repository.loadTasks { result ->
        updateUi(result)
        idlingResource.decrement()
    }
}

Register

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

Unregister

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

Every increment() must have exactly one matching decrement().

---

24. Custom Idling Resources

Create a custom IdlingResource when existing implementations cannot represent the operation.

class LoadingIdlingResource(
    private val isLoading: () -> Boolean
) : IdlingResource {

    @Volatile
    private var callback:
        IdlingResource.ResourceCallback? = null

    override fun getName(): String {
        return "LoadingIdlingResource"
    }

    override fun isIdleNow(): Boolean {
        return !isLoading()
    }

    override fun registerIdleTransitionCallback(
        callback: IdlingResource.ResourceCallback
    ) {
        this.callback = callback
    }

    fun notifyLoadingChanged() {
        if (!isLoading()) {
            callback?.onTransitionToIdle()
        }
    }
}

Important rules

* Register before asynchronous work starts.

* Unregister after the test.

* Notify Espresso when the resource becomes idle.

* Do not hold Activity or View references.

* Keep tracked state simple.

* Do not perform expensive work in isIdleNow.

* Ensure every busy state can return to idle.

* Avoid adding unnecessary test logic to production.

---

Complete Use Case: Login UI Test

flowchart TD
    L["Launch LoginActivity"]
    I["Enter credentials"]
    C["Click Login"]
    W["Wait for authentication"]
    R{"Result"}
    H["Display Home"]
    E["Display Error"]

    L --> I --> C --> W --> R
    R -->|Success| H
    R -->|Failure| E

Testing matrix

BehaviourTest
Validation rulesJUnit
ViewModel stateJUnit
String resourcesRobolectric
Activity launchActivityScenario
Rotation/recreationActivityScenario
Fragment argumentsFragmentScenario
Form interactionEspresso
Error renderingEspresso
Account listRecyclerViewActions
Asynchronous loginFake repository/IdlingResource
Complete journeySelected instrumented E2E test

---

Common Mistakes

1. Running every test on a device

2. Using Robolectric for pure Kotlin code

3. Assuming Robolectric equals a real device

4. Measuring performance with Robolectric

5. Putting tests in the wrong source set

6. Forgetting AndroidJUnitRunner configuration

7. Forgetting to close ActivityScenario

8. Testing private Activity fields

9. Testing lifecycle callback calls instead of effects

10. Treating recreation as process death

11. Using deprecated Fragment controllers

12. Forgetting the Fragment theme

13. Using ambiguous text matchers

14. Depending on RecyclerView positions

15. Using Thread.sleep()

16. Calling a real backend

17. Registering IdlingResource too late

18. Forgetting to unregister IdlingResource

19. Unbalanced increment/decrement calls

20. Holding Views in an IdlingResource

21. Over-testing Toast timing

22. Creating one giant E2E test

23. Sharing test data between tests

24. Ignoring animations and device state

---

Interview Questions and Answers

1. Local test vs instrumented test?

Local tests run quickly on the JVM. Instrumented tests run on Android and verify real framework behaviour.

2. Where are local tests stored?

src/test

3. Where are instrumented tests stored?

src/androidTest

4. What is AndroidJUnitRunner?

It is the test runner used to execute instrumented JUnit 4 tests on Android.

5. Is AndroidJUnitRunner required for Robolectric?

No. Robolectric local tests commonly use RobolectricTestRunner or compatible AndroidX Test setup.

6. Emulator vs physical device?

Emulators are configurable and scalable. Physical devices provide real hardware and manufacturer behaviour.

7. How do you select a framework?

Use the fastest framework that provides sufficient confidence for the requirement.

8. What is Robolectric?

It simulates Android framework behaviour on the JVM.

9. What is a Robolectric shadow?

A JVM implementation or representation of an Android framework class.

10. What is Robolectric’s main advantage?

It runs Android-dependent tests faster without starting a device.

11. Can Robolectric replace every device test?

No. It cannot fully reproduce hardware, OEM, rendering and real framework behaviour.

12. What is ActivityScenario?

An API for launching an Activity and controlling its lifecycle in a test.

13. Why close ActivityScenario?

To clean up the Activity and prevent test-state leakage.

14. How do you test Activity recreation?

Call scenario.recreate() and verify that required state remains.

15. Recreation vs process death?

During recreation, ViewModel can survive. During process death, the process and ViewModels are destroyed.

16. What is FragmentScenario?

An API for launching and controlling an AndroidX Fragment in isolation.

17. How do you provide Fragment arguments?

Pass a Bundle through fragmentArgs.

18. What is Espresso?

A UI-testing framework for Android View-based screens.

19. What are Espresso’s three main steps?

Find a View, perform an action and check the result.

20. What is a ViewMatcher?

A condition used to locate a View.

21. What is a ViewAction?

An operation such as click, type or scroll.

22. What is a ViewAssertion?

A verification applied to a matched View.

23. How do you test RecyclerView?

Use RecyclerViewActions to scroll to or act on an item.

24. Why avoid fixed item positions?

Sorting, filtering and data changes can move an item and make tests fragile.

25. How do you test a dialog?

Open it, select its dialog root and verify its content and actions.

26. Why are Toast tests less reliable?

Toasts are temporary, system-managed and can behave differently across Android versions.

27. What is Espresso synchronization?

Espresso waits until the main thread and known asynchronous resources become idle.

28. Does Espresso know about every background operation?

No. Custom executors, services and callbacks may require an IdlingResource.

29. What is an IdlingResource?

It communicates an asynchronous operation’s busy or idle state to Espresso.

30. What is CountingIdlingResource?

It tracks active operations with a counter and is idle when the counter becomes zero.

31. How do you register an IdlingResource?

IdlingRegistry.getInstance().register(resource)

32. Why unregister it?

To prevent it from affecting other tests or leaking resources.

33. Why avoid Thread.sleep()?

It creates slow, timing-dependent tests without guaranteeing completion.

34. What should UI tests verify?

Observable user behaviour, important screen states and critical journeys.

---

Quick Revision

src/test
    → JVM
    → JUnit/Robolectric
    → Fast

src/androidTest
    → Device/emulator
    → AndroidJUnitRunner
    → Espresso

Robolectric
    → Simulated Android environment

ActivityScenario
    → Launch, lifecycle and recreation

FragmentScenario
    → Fragment isolation and lifecycle

Espresso
    → onView()
    → perform()
    → check()

IdlingResource
    → Synchronizes custom asynchronous work

Avoid:
Thread.sleep()
Real backend
Fragile RecyclerView positions
Unclosed scenarios
Unregistered IdlingResources