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 + inexpensiveInstrumented 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 maintenanceComparison
| Point | Local | Instrumented |
|---|---|---|
| Environment | JVM | Device/emulator |
| Speed | Fast | Slower |
| Android behaviour | Mocked/simulated | Real |
| Common tools | JUnit, Robolectric | Espresso, AndroidX Test |
| Reliability | Usually higher | Environment-dependent |
| Framework confidence | Lower | Higher |
---
2. src/test vs src/androidTest
app/
└── src/
├── main/
│ └── java/
├── test/
│ └── java/
│ └── LoginViewModelTest.kt
└── androidTest/
└── java/
└── LoginActivityTest.kt| Directory | Contains | Runs on |
|---|---|---|
src/main | Production code | Application |
src/test | Local/Robolectric tests | JVM |
src/androidTest | Instrumented tests | Device/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| Requirement | Framework |
|---|---|
| Business logic | JUnit |
| ViewModel state | JUnit + coroutine test |
| Android resources | Robolectric |
| Activity lifecycle locally | ActivityScenario + Robolectric |
| Fragment locally | FragmentScenario + Robolectric |
| XML/View UI | Espresso |
| Real database/framework | Instrumented test |
| Cross-app interaction | UI Automator |
| Performance | Benchmark 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 --> ORobolectric 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 MainActivityTestTest 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
)
}
}
}| State | Activity condition |
|---|---|
CREATED | Created/stopped |
STARTED | Visible, not interactive |
RESUMED | Foreground and interactive |
DESTROYED | Destroyed |
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 resultEspresso 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 proceedsCountingIdlingResource
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| ETesting matrix
| Behaviour | Test |
|---|---|
| Validation rules | JUnit |
| ViewModel state | JUnit |
| String resources | Robolectric |
| Activity launch | ActivityScenario |
| Rotation/recreation | ActivityScenario |
| Fragment arguments | FragmentScenario |
| Form interaction | Espresso |
| Error rendering | Espresso |
| Account list | RecyclerViewActions |
| Asynchronous login | Fake repository/IdlingResource |
| Complete journey | Selected 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/test3. Where are instrumented tests stored?
src/androidTest4. 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