Espresso (as discussed in Mobile Testing in 2025: iOS, Android and Beyond) (as discussed in Appium 2.0: New Architecture and Cloud Integration for Modern Mobile Testing) (as discussed in Detox: Grey-Box Testing for React Native Applications) and XCUITest represent Google’s and Apple’s official approaches to native mobile testing, providing deep integration with their respective platforms. These frameworks offer superior performance, reliability, and access to platform-specific features that cross-platform tools cannot match.
Espresso: Android’s Native Testing Framework
Architecture and Core Concepts
Espresso operates directly on the Android instrumentation layer, providing automatic synchronization with the UI thread:
// build.gradle (app module)
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-accessibility:3.5.1'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test:rules:1.5.0'
androidTestImplementation 'androidx.test:runner:1.5.2'
}
android {
defaultConfig {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
}
Espresso Test Structure
Basic test anatomy using ViewMatchers, ViewActions, and ViewAssertions:
@RunWith(AndroidJUnit4::class)
@LargeTest
class LoginActivityTest {
@get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java)
@Test
fun validLogin_navigatesToDashboard() {
// ViewMatchers - locate UI elements
onView(withId(R.id.email_input))
.perform(typeText("user@example.com"), closeSoftKeyboard())
onView(withId(R.id.password_input))
.perform(typeText("password123"), closeSoftKeyboard())
// ViewActions - interact with elements
onView(withId(R.id.login_button))
.perform(click())
// ViewAssertions - verify results
onView(withId(R.id.dashboard_layout))
.check(matches(isDisplayed()))
onView(withText("Welcome, User"))
.check(matches(isDisplayed()))
}
@Test
fun invalidEmail_showsValidationError() {
onView(withId(R.id.email_input))
.perform(typeText("invalid-email"), closeSoftKeyboard())
onView(withId(R.id.login_button))
.perform(click())
onView(withText("Invalid email format"))
.check(matches(isDisplayed()))
}
}
Advanced ViewMatchers
Combining matchers for precise element selection:
class AdvancedMatchersTest {
@Test
fun complexViewMatching() {
// Combining matchers with allOf
onView(allOf(
withId(R.id.submit_button),
withText("Submit"),
isEnabled()
)).perform(click())
// Negation with not()
onView(allOf(
withId(R.id.username_input),
not(withText(""))
)).check(matches(isDisplayed()))
// Child matching
onView(allOf(
withId(R.id.error_text),
withParent(withId(R.id.email_container))
)).check(matches(withText("Required field")))
// Descendant matching
onView(allOf(
withText("Item Title"),
isDescendantOfA(withId(R.id.recycler_view))
)).perform(click())
}
}
RecyclerView Testing
Testing complex list interactions:
class RecyclerViewTest {
@Test
fun scrollToItemAndClick() {
// Scroll to position
onView(withId(R.id.products_recycler))
.perform(scrollToPosition<RecyclerView.ViewHolder>(50))
// Scroll to item matching criteria
onView(withId(R.id.products_recycler))
.perform(scrollTo<RecyclerView.ViewHolder>(
hasDescendant(withText("Product 50"))
))
// Click on specific item
onView(withId(R.id.products_recycler))
.perform(actionOnItemAtPosition<RecyclerView.ViewHolder>(
50, click()
))
// Custom ViewAction on matched item
onView(withId(R.id.products_recycler))
.perform(actionOnItem<RecyclerView.ViewHolder>(
hasDescendant(withText("Product 50")),
click()
))
}
@Test
fun verifyRecyclerViewContent() {
// Count items
onView(withId(R.id.products_recycler))
.check(matches(hasChildCount(100)))
// Verify item at position
onView(withId(R.id.products_recycler))
.perform(scrollToPosition<RecyclerView.ViewHolder>(25))
.check(matches(atPosition(25, hasDescendant(withText("Product 25")))))
}
}
// Custom RecyclerView matcher
fun atPosition(position: Int, itemMatcher: Matcher<View>): Matcher<View> {
return object : BoundedMatcher<View, RecyclerView>(RecyclerView::class.java) {
override fun describeTo(description: Description) {
description.appendText("has item at position $position: ")
itemMatcher.describeTo(description)
}
override fun matchesSafely(view: RecyclerView): Boolean {
val viewHolder = view.findViewHolderForAdapterPosition(position)
?: return false
return itemMatcher.matches(viewHolder.itemView)
}
}
}
Intent Testing with Espresso-Intents
Validate and mock intents:
@RunWith(AndroidJUnit4::class)
class IntentsTest {
@get:Rule
val activityRule = ActivityScenarioRule(MainActivity::class.java)
@get:Rule
val intentsRule = IntentsTestRule(MainActivity::class.java)
@Test
fun shareButton_sendsShareIntent() {
// Setup intent verification
intending(hasAction(Intent.ACTION_SEND))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))
// Trigger share
onView(withId(R.id.share_button)).perform(click())
// Verify intent was sent
intended(allOf(
hasAction(Intent.ACTION_SEND),
hasType("text/plain"),
hasExtra(Intent.EXTRA_TEXT, "Check out this app!")
))
}
@Test
fun mockCameraIntent() {
// Create mock result
val resultData = Intent().apply {
putExtra("imagePath", "/mock/path/image.jpg")
}
// Mock camera intent
intending(hasAction(MediaStore.ACTION_IMAGE_CAPTURE))
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, resultData))
onView(withId(R.id.camera_button)).perform(click())
// Verify UI updated with mock data
onView(withId(R.id.image_preview))
.check(matches(isDisplayed()))
}
}
Idling Resources
Handle asynchronous operations:
class NetworkIdlingResource : IdlingResource {
private var callback: IdlingResource.ResourceCallback? = null
@Volatile
private var requestCount = 0
fun incrementRequests() {
requestCount++
}
fun decrementRequests() {
requestCount--
if (requestCount == 0) {
callback?.onTransitionToIdle()
}
}
override fun getName(): String = "NetworkIdlingResource"
override fun isIdleNow(): Boolean = requestCount == 0
override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) {
this.callback = callback
}
}
// Usage in tests
class NetworkTest {
private lateinit var idlingResource: NetworkIdlingResource
@Before
fun setup() {
idlingResource = NetworkIdlingResource()
IdlingRegistry.getInstance().register(idlingResource)
}
@After
fun teardown() {
IdlingRegistry.getInstance().unregister(idlingResource)
}
@Test
fun loadData_displaysResults() {
onView(withId(R.id.load_button)).perform(click())
// Espresso waits for idling resource
onView(withId(R.id.results_list))
.check(matches(isDisplayed()))
}
}
XCUITest: iOS Native Testing Framework
XCTest Setup and Structure
XCUITest integrates directly with Xcode and the iOS simulator:
import XCTest
class LoginTests: XCTestCase {
var app: XCUIApplication!
override func setUpWithError() throws {
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = ["UI-TESTING"]
app.launch()
}
override func tearDownWithError() throws {
app = nil
}
func testValidLogin() throws {
// Element queries
let emailField = app.textFields["emailInput"]
let passwordField = app.secureTextFields["passwordInput"]
let loginButton = app.buttons["loginButton"]
// Interactions
emailField.tap()
emailField.typeText("user@example.com")
passwordField.tap()
passwordField.typeText("password123")
loginButton.tap()
// Assertions
let dashboardLabel = app.staticTexts["Welcome"]
XCTAssertTrue(dashboardLabel.waitForExistence(timeout: 5))
XCTAssertTrue(dashboardLabel.isHittable)
}
func testInvalidEmailValidation() throws {
let emailField = app.textFields["emailInput"]
let loginButton = app.buttons["loginButton"]
emailField.tap()
emailField.typeText("invalid-email")
loginButton.tap()
let errorLabel = app.staticTexts["Invalid email format"]
XCTAssertTrue(errorLabel.exists)
}
}
Advanced Element Queries
XCUITest provides powerful query mechanisms:
class AdvancedQueriesTests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
app = XCUIApplication()
app.launch()
}
func testComplexQueries() {
// Query by type and predicate
let buttons = app.buttons.matching(
NSPredicate(format: "label CONTAINS[c] 'submit'")
)
XCTAssertEqual(buttons.count, 1)
buttons.firstMatch.tap()
// Descendant queries
let tableView = app.tables["productList"]
let cell = tableView.cells.element(boundBy: 5)
let cellButton = cell.buttons["addToCart"]
cellButton.tap()
// Combining queries
let enabledButtons = app.buttons.matching(
NSPredicate(format: "isEnabled == true")
)
// Index-based access
let thirdButton = app.buttons.element(boundBy: 2)
thirdButton.tap()
// Unique identifier
let specificButton = app.buttons["uniqueIdentifier"]
XCTAssertTrue(specificButton.exists)
}
func testTableViewInteractions() {
let table = app.tables["productList"]
// Scroll to element
let cell50 = table.cells.element(boundBy: 50)
while !cell50.isHittable {
app.swipeUp()
}
// Alternative: use scrollToElement (custom extension)
table.scrollToElement(element: cell50)
cell50.tap()
// Verify cell content
let cellTitle = cell50.staticTexts["Product 50"]
XCTAssertTrue(cellTitle.exists)
}
}
// Custom extension for scrolling
extension XCUIElement {
func scrollToElement(element: XCUIElement) {
while !element.isHittable {
swipeUp()
}
}
}
Gesture Interactions
Comprehensive gesture support:
class GestureTests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
app = XCUIApplication()
app.launch()
}
func testVariousGestures() {
let imageView = app.images["photoImage"]
// Tap gestures
imageView.tap()
imageView.doubleTap()
imageView.twoFingerTap()
// Press gestures
imageView.press(forDuration: 2.0)
// Swipe gestures
imageView.swipeLeft()
imageView.swipeRight()
imageView.swipeUp()
imageView.swipeDown()
// Pinch gestures
imageView.pinch(withScale: 2.0, velocity: 1.0)
imageView.pinch(withScale: 0.5, velocity: -1.0)
// Rotate gesture
imageView.rotate(CGFloat.pi/2, withVelocity: 1.0)
}
func testCoordinateBasedInteractions() {
let view = app.otherElements["customView"]
// Tap at specific coordinate
let coordinate = view.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
coordinate.tap()
// Drag from point to point
let startCoordinate = view.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5))
let endCoordinate = view.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5))
startCoordinate.press(forDuration: 0.1, thenDragTo: endCoordinate)
}
}
Handling Alerts and System Dialogs
class AlertTests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
app = XCUIApplication()
app.launch()
}
func testLocationPermissionAlert() {
// Trigger location permission
app.buttons["enableLocationButton"].tap()
// Handle system alert
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
let allowButton = springboard.buttons["Allow While Using App"]
if allowButton.waitForExistence(timeout: 5) {
allowButton.tap()
}
// Verify permission granted
XCTAssertTrue(app.staticTexts["Location Enabled"].exists)
}
func testNotificationPermission() {
app.buttons["enableNotificationsButton"].tap()
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
let allowButton = springboard.buttons["Allow"]
if allowButton.waitForExistence(timeout: 5) {
allowButton.tap()
}
}
func testAppAlert() {
app.buttons["showAlertButton"].tap()
let alert = app.alerts["Confirmation"]
XCTAssertTrue(alert.exists)
let confirmButton = alert.buttons["Confirm"]
confirmButton.tap()
XCTAssertFalse(alert.exists)
}
}
Screenshot and Activity Recording
class ScreenshotTests: XCTestCase {
var app: XCUIApplication!
override func setUp() {
app = XCUIApplication()
app.launch()
}
func testLoginFlowWithScreenshots() {
// Take screenshot at start
let screenshot1 = app.screenshot()
let attachment1 = XCTAttachment(screenshot: screenshot1)
attachment1.name = "Login Screen"
attachment1.lifetime = .keepAlways
add(attachment1)
// Perform login
app.textFields["emailInput"].tap()
app.textFields["emailInput"].typeText("user@example.com")
let screenshot2 = app.screenshot()
let attachment2 = XCTAttachment(screenshot: screenshot2)
attachment2.name = "Email Entered"
add(attachment2)
app.secureTextFields["passwordInput"].tap()
app.secureTextFields["passwordInput"].typeText("password123")
app.buttons["loginButton"].tap()
// Wait for dashboard
XCTAssertTrue(app.staticTexts["Welcome"].waitForExistence(timeout: 5))
let screenshot3 = app.screenshot()
let attachment3 = XCTAttachment(screenshot: screenshot3)
attachment3.name = "Dashboard Loaded"
add(attachment3)
}
}
Platform Comparison
Feature Comparison Matrix
Feature | Espresso | XCUITest |
---|---|---|
Synchronization | Automatic (UI thread) | Manual waits required |
Speed | Very fast (in-process) | Moderate (out-of-process) |
Flakiness | Low | Medium |
Learning curve | Moderate | Moderate |
IDE integration | Android Studio | Xcode only |
CI/CD support | Excellent | Excellent |
Network mocking | Via OkHttp Interceptor | Via URLProtocol |
Accessibility testing | espresso-accessibility | Built-in |
Screenshot support | Manual implementation | Native XCTAttachment |
Test Execution Speed
// Espresso - runs in same process as app
@Test
fun measureEspressoSpeed() {
val startTime = System.currentTimeMillis()
repeat(100) {
onView(withId(R.id.button)).perform(click())
onView(withId(R.id.text)).check(matches(isDisplayed()))
}
val duration = System.currentTimeMillis() - startTime
println("Espresso 100 iterations: ${duration}ms") // ~2-3 seconds
}
// XCUITest - runs in separate process
func testXCUITestSpeed() {
let startTime = Date()
for _ in 0..<100 {
app.buttons["button"].tap()
XCTAssertTrue(app.staticTexts["text"].exists)
}
let duration = Date().timeIntervalSince(startTime)
print("XCUITest 100 iterations: \(duration)s") // ~10-15 seconds
}
CI/CD Integration
Espresso with GitHub Actions
name: Android Espresso Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
espresso-tests:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 11
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '11'
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Run Espresso tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 33
target: google_apis
arch: x86_64
profile: Nexus 6
script: ./gradlew connectedAndroidTest
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: espresso-test-results
path: app/build/reports/androidTests/
XCUITest with Fastlane
# Fastfile
default_platform(:ios)
platform :ios do
desc "Run XCUITests"
lane :ui_tests do
scan(
scheme: "MyApp",
devices: ["iPhone 14 Pro"],
clean: true,
code_coverage: true,
output_directory: "./test_output",
output_types: "html,junit",
fail_build: false
)
end
desc "Run XCUITests on multiple devices"
lane :ui_tests_multi_device do
scan(
scheme: "MyApp",
devices: [
"iPhone 14 Pro",
"iPhone SE (3rd generation)",
"iPad Pro (12.9-inch) (6th generation)"
],
clean: true
)
end
end
XCUITest GitHub Actions
name: iOS XCUITest
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
xcuitest:
runs-on: macos-latest
steps:
- uses: actions/checkout@v3
- name: Select Xcode version
run: sudo xcode-select -s /Applications/Xcode_14.3.app
- name: Install dependencies
run: |
gem install bundler
bundle install
- name: Run XCUITests
run: |
xcodebuild test \
-scheme MyApp \
-destination 'platform=iOS Simulator,name=iPhone 14 Pro,OS=16.4' \
-resultBundlePath TestResults.xcresult \
-enableCodeCoverage YES
- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: xcuitest-results
path: TestResults.xcresult
Best Practices
Page Object Model in Espresso
class LoginPage {
private val emailInput = onView(withId(R.id.email_input))
private val passwordInput = onView(withId(R.id.password_input))
private val loginButton = onView(withId(R.id.login_button))
private val errorMessage = onView(withId(R.id.error_message))
fun enterEmail(email: String): LoginPage {
emailInput.perform(typeText(email), closeSoftKeyboard())
return this
}
fun enterPassword(password: String): LoginPage {
passwordInput.perform(typeText(password), closeSoftKeyboard())
return this
}
fun clickLogin(): DashboardPage {
loginButton.perform(click())
return DashboardPage()
}
fun verifyErrorMessage(message: String) {
errorMessage.check(matches(withText(message)))
}
}
// Usage
@Test
fun testLoginFlow() {
LoginPage()
.enterEmail("user@example.com")
.enterPassword("password123")
.clickLogin()
.verifyDashboardDisplayed()
}
Page Object Model in XCUITest
class LoginPage {
private let app: XCUIApplication
private var emailField: XCUIElement {
app.textFields["emailInput"]
}
private var passwordField: XCUIElement {
app.secureTextFields["passwordInput"]
}
private var loginButton: XCUIElement {
app.buttons["loginButton"]
}
init(app: XCUIApplication) {
self.app = app
}
@discardableResult
func enterEmail(_ email: String) -> Self {
emailField.tap()
emailField.typeText(email)
return self
}
@discardableResult
func enterPassword(_ password: String) -> Self {
passwordField.tap()
passwordField.typeText(password)
return self
}
func tapLogin() -> DashboardPage {
loginButton.tap()
return DashboardPage(app: app)
}
}
// Usage
func testLoginFlow() {
LoginPage(app: app)
.enterEmail("user@example.com")
.enterPassword("password123")
.tapLogin()
.verifyDashboardDisplayed()
}
Conclusion
Espresso and XCUITest provide unmatched performance and reliability for platform-specific mobile testing. Espresso’s in-process execution model delivers exceptional speed for Android testing, while XCUITest’s integration with Xcode provides comprehensive tooling for iOS development.
For teams committed to native mobile development, mastering these frameworks enables creation of robust, fast, and maintainable test suites that leverage each platform’s unique capabilities. The investment in platform-specific expertise pays dividends in test reliability and execution speed compared to cross-platform alternatives.