UI Test Development for Android Application (Espresso)
Espresso is instrumental UI test framework from Google, built into Android SDK. Runs in same process as app, giving advantage: UI thread synchronization automatic. Espresso knows when app is "busy" — unlike UIAutomator, doesn't need sleep() between actions.
Basics and Critical Nuances
Basic Espresso test structure: onView(matcher).perform(action).check(assertion).
@Test
fun loginWithValidCredentials_navigatesToHome() {
onView(withId(R.id.emailInput))
.perform(typeText("[email protected]"), closeSoftKeyboard())
onView(withId(R.id.passwordInput))
.perform(typeText("password123"), closeSoftKeyboard())
onView(withId(R.id.loginButton))
.perform(click())
onView(withId(R.id.homeTitle))
.check(matches(isDisplayed()))
}
Most common reason for flaky tests — IdlingResource. If app does async operation (Retrofit, Coroutine), Espresso doesn't know and tries finding next element before operation completes. Solution — register IdlingResource:
// For OkHttp/Retrofit
val idlingResource = OkHttpIdlingResource.create("okhttp", okHttpClient)
IdlingRegistry.getInstance().register(idlingResource)
For coroutines — IdlingCoroutineDispatcher or EspressoIdlingResource from Google.
Compose UI Testing
If project uses Jetpack Compose, Espresso approach replaced with ComposeTestRule:
@get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Test
fun loginScreen_showsErrorOnInvalidEmail() {
composeTestRule.onNodeWithTag("emailInput")
.performTextInput("invalid-email")
composeTestRule.onNodeWithTag("loginButton")
.performClick()
composeTestRule.onNodeWithText("Неверный формат email")
.assertIsDisplayed()
}
testTag in Compose — analog of accessibilityIdentifier in iOS. Mandatory to set on all testable elements via Modifier.testTag("loginButton").
Hilt and DI in Instrumented Tests
If project uses Hilt, tests require HiltAndroidRule:
@HiltAndroidTest
class LoginScreenTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<MainActivity>()
@BindValue
val authRepository: AuthRepository = FakeAuthRepository()
@Before
fun init() { hiltRule.inject() }
}
@BindValue allows substituting real repository with fake without changing main code — test doesn't depend on network or database.
Robot Pattern (Page Object Analog)
class LoginRobot(private val composeTestRule: AndroidComposeTestRule<*, *>) {
fun enterEmail(email: String) = apply {
composeTestRule.onNodeWithTag("emailInput").performTextInput(email)
}
fun enterPassword(password: String) = apply {
composeTestRule.onNodeWithTag("passwordInput").performTextInput(password)
}
fun clickLogin() = apply {
composeTestRule.onNodeWithTag("loginButton").performClick()
}
fun assertHomeVisible() {
composeTestRule.onNodeWithTag("homeScreen").assertIsDisplayed()
}
}
// Test reads as scenario
@Test
fun validLogin_showsHome() {
LoginRobot(composeTestRule)
.enterEmail("[email protected]")
.enterPassword("pass123")
.clickLogin()
.assertHomeVisible()
}
CI: Firebase Test Lab
Local emulator sufficient for development, but before release — Firebase Test Lab with real devices:
- name: Run Espresso tests on Firebase Test Lab
run: |
gcloud firebase test android run \
--type instrumentation \
--app app/build/outputs/apk/debug/app-debug.apk \
--test app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
--device model=Pixel8,version=34 \
--device model=GalaxyS23,version=33
Results — Logcat, screenshots on failure, video of run — saved to Google Cloud Storage.
Testing Scope
Critical flows: login/registration, payment, main user path. Edge cases: deep link when not logged in, push notification tap, return from background. Accessibility: TalkBack via UIAutomator + AccessibilityChecks from Google (AccessibilityChecks.enable() in setUp()).
Timeframe: 3–5 days for basic suite of critical flows with Robot pattern, Hilt integration and CI on Firebase Test Lab.







