Espresso (como se discute en Mobile Testing in 2025: iOS, Android and Beyond) (como se discute en Appium 2.0: New Architecture and Cloud Integration for Modern Mobile Testing) (como se discute en Detox: Grey-Box Testing for React Native Applications) y XCUITest representan los enfoques oficiales de Google y Apple para el testing móvil nativo, proporcionando integración profunda con sus respectivas plataformas. Estos frameworks ofrecen rendimiento superior, confiabilidad y acceso a características específicas de la plataforma que las herramientas cross-platform no pueden igualar.

Espresso: Framework Nativo de Testing para Android

Arquitectura y Conceptos Core

Espresso opera directamente en la capa de instrumentación de Android, proporcionando sincronización automática con el hilo de UI:

// build.gradle (módulo app)
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"
    }
}

Estructura de Pruebas Espresso

Anatomía básica de prueba usando ViewMatchers, ViewActions y ViewAssertions:

@RunWith(AndroidJUnit4::class)
@LargeTest
class LoginActivityTest {

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

    @Test
    fun validLogin_navigatesToDashboard() {
        // ViewMatchers - localizar elementos UI
        onView(withId(R.id.email_input))
            .perform(typeText("user@example.com"), closeSoftKeyboard())

        onView(withId(R.id.password_input))
            .perform(typeText("password123"), closeSoftKeyboard())

        // ViewActions - interactuar con elementos
        onView(withId(R.id.login_button))
            .perform(click())

        // ViewAssertions - verificar resultados
        onView(withId(R.id.dashboard_layout))
            .check(matches(isDisplayed()))

        onView(withText("Bienvenido, Usuario"))
            .check(matches(isDisplayed()))
    }

    @Test
    fun invalidEmail_showsValidationError() {
        onView(withId(R.id.email_input))
            .perform(typeText("email-invalido"), closeSoftKeyboard())

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

        onView(withText("Formato de email inválido"))
            .check(matches(isDisplayed()))
    }
}

ViewMatchers Avanzados

Combinando matchers para selección precisa de elementos:

class AdvancedMatchersTest {

    @Test
    fun complexViewMatching() {
        // Combinando matchers con allOf
        onView(allOf(
            withId(R.id.submit_button),
            withText("Enviar"),
            isEnabled()
        )).perform(click())

        // Negación con not()
        onView(allOf(
            withId(R.id.username_input),
            not(withText(""))
        )).check(matches(isDisplayed()))

        // Coincidencia de hijo
        onView(allOf(
            withId(R.id.error_text),
            withParent(withId(R.id.email_container))
        )).check(matches(withText("Campo requerido")))

        // Coincidencia de descendiente
        onView(allOf(
            withText("Título de Item"),
            isDescendantOfA(withId(R.id.recycler_view))
        )).perform(click())
    }
}

Testing de RecyclerView

Testing de interacciones complejas de lista:

class RecyclerViewTest {

    @Test
    fun scrollToItemAndClick() {
        // Scrollear a posición
        onView(withId(R.id.products_recycler))
            .perform(scrollToPosition<RecyclerView.ViewHolder>(50))

        // Scrollear a item que coincide con criterio
        onView(withId(R.id.products_recycler))
            .perform(scrollTo<RecyclerView.ViewHolder>(
                hasDescendant(withText("Producto 50"))
            ))

        // Click en item específico
        onView(withId(R.id.products_recycler))
            .perform(actionOnItemAtPosition<RecyclerView.ViewHolder>(
                50, click()
            ))

        // ViewAction personalizado en item coincidente
        onView(withId(R.id.products_recycler))
            .perform(actionOnItem<RecyclerView.ViewHolder>(
                hasDescendant(withText("Producto 50")),
                click()
            ))
    }

    @Test
    fun verifyRecyclerViewContent() {
        // Contar items
        onView(withId(R.id.products_recycler))
            .check(matches(hasChildCount(100)))

        // Verificar item en posición
        onView(withId(R.id.products_recycler))
            .perform(scrollToPosition<RecyclerView.ViewHolder>(25))
            .check(matches(atPosition(25, hasDescendant(withText("Producto 25")))))
    }
}

// Matcher personalizado para RecyclerView
fun atPosition(position: Int, itemMatcher: Matcher<View>): Matcher<View> {
    return object : BoundedMatcher<View, RecyclerView>(RecyclerView::class.java) {
        override fun describeTo(description: Description) {
            description.appendText("tiene item en posición $position: ")
            itemMatcher.describeTo(description)
        }

        override fun matchesSafely(view: RecyclerView): Boolean {
            val viewHolder = view.findViewHolderForAdapterPosition(position)
                ?: return false
            return itemMatcher.matches(viewHolder.itemView)
        }
    }
}

Testing de Intents con Espresso-Intents

Validar y mockear 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() {
        // Configurar verificación de intent
        intending(hasAction(Intent.ACTION_SEND))
            .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null))

        // Disparar compartir
        onView(withId(R.id.share_button)).perform(click())

        // Verificar intent fue enviado
        intended(allOf(
            hasAction(Intent.ACTION_SEND),
            hasType("text/plain"),
            hasExtra(Intent.EXTRA_TEXT, "¡Mira esta app!")
        ))
    }

    @Test
    fun mockCameraIntent() {
        // Crear resultado mock
        val resultData = Intent().apply {
            putExtra("imagePath", "/mock/path/image.jpg")
        }

        // Mockear intent de cámara
        intending(hasAction(MediaStore.ACTION_IMAGE_CAPTURE))
            .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, resultData))

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

        // Verificar UI actualizado con datos mock
        onView(withId(R.id.image_preview))
            .check(matches(isDisplayed()))
    }
}

Idling Resources

Manejar operaciones asíncronas:

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
    }
}

// Uso en pruebas
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 espera al idling resource
        onView(withId(R.id.results_list))
            .check(matches(isDisplayed()))
    }
}

XCUITest: Framework Nativo de Testing para iOS

Configuración y Estructura de XCTest

XCUITest se integra directamente con Xcode y el simulador de iOS:

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 {
        // Consultas de elementos
        let emailField = app.textFields["emailInput"]
        let passwordField = app.secureTextFields["passwordInput"]
        let loginButton = app.buttons["loginButton"]

        // Interacciones
        emailField.tap()
        emailField.typeText("user@example.com")

        passwordField.tap()
        passwordField.typeText("password123")

        loginButton.tap()

        // Aserciones
        let dashboardLabel = app.staticTexts["Bienvenido"]
        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("email-invalido")
        loginButton.tap()

        let errorLabel = app.staticTexts["Formato de email inválido"]
        XCTAssertTrue(errorLabel.exists)
    }
}

Consultas de Elementos Avanzadas

XCUITest proporciona mecanismos de consulta poderosos:

class AdvancedQueriesTests: XCTestCase {
    var app: XCUIApplication!

    override func setUp() {
        app = XCUIApplication()
        app.launch()
    }

    func testComplexQueries() {
        // Consulta por tipo y predicado
        let buttons = app.buttons.matching(
            NSPredicate(format: "label CONTAINS[c] 'enviar'")
        )
        XCTAssertEqual(buttons.count, 1)
        buttons.firstMatch.tap()

        // Consultas de descendientes
        let tableView = app.tables["productList"]
        let cell = tableView.cells.element(boundBy: 5)
        let cellButton = cell.buttons["addToCart"]
        cellButton.tap()

        // Combinando consultas
        let enabledButtons = app.buttons.matching(
            NSPredicate(format: "isEnabled == true")
        )

        // Acceso basado en índice
        let thirdButton = app.buttons.element(boundBy: 2)
        thirdButton.tap()

        // Identificador único
        let specificButton = app.buttons["uniqueIdentifier"]
        XCTAssertTrue(specificButton.exists)
    }

    func testTableViewInteractions() {
        let table = app.tables["productList"]

        // Scrollear a elemento
        let cell50 = table.cells.element(boundBy: 50)
        while !cell50.isHittable {
            app.swipeUp()
        }

        // Alternativa: usar scrollToElement (extensión personalizada)
        table.scrollToElement(element: cell50)
        cell50.tap()

        // Verificar contenido de celda
        let cellTitle = cell50.staticTexts["Producto 50"]
        XCTAssertTrue(cellTitle.exists)
    }
}

// Extensión personalizada para scrolling
extension XCUIElement {
    func scrollToElement(element: XCUIElement) {
        while !element.isHittable {
            swipeUp()
        }
    }
}

Interacciones con Gestos

Soporte comprehensivo de gestos:

class GestureTests: XCTestCase {
    var app: XCUIApplication!

    override func setUp() {
        app = XCUIApplication()
        app.launch()
    }

    func testVariousGestures() {
        let imageView = app.images["photoImage"]

        // Gestos de tap
        imageView.tap()
        imageView.doubleTap()
        imageView.twoFingerTap()

        // Gestos de presión
        imageView.press(forDuration: 2.0)

        // Gestos de swipe
        imageView.swipeLeft()
        imageView.swipeRight()
        imageView.swipeUp()
        imageView.swipeDown()

        // Gestos de pinch
        imageView.pinch(withScale: 2.0, velocity: 1.0)
        imageView.pinch(withScale: 0.5, velocity: -1.0)

        // Gesto de rotación
        imageView.rotate(CGFloat.pi/2, withVelocity: 1.0)
    }

    func testCoordinateBasedInteractions() {
        let view = app.otherElements["customView"]

        // Tap en coordenada específica
        let coordinate = view.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
        coordinate.tap()

        // Arrastrar de punto a punto
        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)
    }
}

Manejo de Alertas y Diálogos del Sistema

class AlertTests: XCTestCase {
    var app: XCUIApplication!

    override func setUp() {
        app = XCUIApplication()
        app.launch()
    }

    func testLocationPermissionAlert() {
        // Disparar permiso de ubicación
        app.buttons["enableLocationButton"].tap()

        // Manejar alerta del sistema
        let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
        let allowButton = springboard.buttons["Permitir Mientras se Usa la App"]

        if allowButton.waitForExistence(timeout: 5) {
            allowButton.tap()
        }

        // Verificar permiso otorgado
        XCTAssertTrue(app.staticTexts["Ubicación Habilitada"].exists)
    }

    func testNotificationPermission() {
        app.buttons["enableNotificationsButton"].tap()

        let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
        let allowButton = springboard.buttons["Permitir"]

        if allowButton.waitForExistence(timeout: 5) {
            allowButton.tap()
        }
    }

    func testAppAlert() {
        app.buttons["showAlertButton"].tap()

        let alert = app.alerts["Confirmación"]
        XCTAssertTrue(alert.exists)

        let confirmButton = alert.buttons["Confirmar"]
        confirmButton.tap()

        XCTAssertFalse(alert.exists)
    }
}

Screenshot y Grabación de Actividad

class ScreenshotTests: XCTestCase {
    var app: XCUIApplication!

    override func setUp() {
        app = XCUIApplication()
        app.launch()
    }

    func testLoginFlowWithScreenshots() {
        // Tomar screenshot al inicio
        let screenshot1 = app.screenshot()
        let attachment1 = XCTAttachment(screenshot: screenshot1)
        attachment1.name = "Pantalla de Login"
        attachment1.lifetime = .keepAlways
        add(attachment1)

        // Realizar login
        app.textFields["emailInput"].tap()
        app.textFields["emailInput"].typeText("user@example.com")

        let screenshot2 = app.screenshot()
        let attachment2 = XCTAttachment(screenshot: screenshot2)
        attachment2.name = "Email Ingresado"
        add(attachment2)

        app.secureTextFields["passwordInput"].tap()
        app.secureTextFields["passwordInput"].typeText("password123")
        app.buttons["loginButton"].tap()

        // Esperar dashboard
        XCTAssertTrue(app.staticTexts["Bienvenido"].waitForExistence(timeout: 5))

        let screenshot3 = app.screenshot()
        let attachment3 = XCTAttachment(screenshot: screenshot3)
        attachment3.name = "Dashboard Cargado"
        add(attachment3)
    }
}

Comparación de Plataformas

Matriz de Comparación de Características

CaracterísticaEspressoXCUITest
SincronizaciónAutomática (hilo UI)Esperas manuales requeridas
VelocidadMuy rápida (in-process)Moderada (out-of-process)
InestabilidadBajaMedia
Curva de aprendizajeModeradaModerada
Integración IDEAndroid StudioSolo Xcode
Soporte CI/CDExcelenteExcelente
Mocking de redVia OkHttp InterceptorVia URLProtocol
Testing de accesibilidadespresso-accessibilityIntegrado
Soporte de screenshotsImplementación manualXCTAttachment nativo

Velocidad de Ejecución de Pruebas

// Espresso - corre en mismo proceso que la 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 iteraciones: ${duration}ms") // ~2-3 segundos
}
// XCUITest - corre en proceso separado
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 iteraciones: \(duration)s") // ~10-15 segundos
}

Integración CI/CD

Espresso con GitHub Actions

name: Pruebas Android Espresso

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

jobs:
  espresso-tests:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v3

      - name: Configurar JDK 11
        uses: actions/setup-java@v3
        with:
          distribution: 'temurin'
          java-version: '11'

      - name: Dar permiso de ejecución a gradlew
        run: chmod +x gradlew

      - name: Ejecutar pruebas Espresso
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 33
          target: google_apis
          arch: x86_64
          profile: Nexus 6
          script: ./gradlew connectedAndroidTest

      - name: Subir resultados de pruebas
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: espresso-test-results
          path: app/build/reports/androidTests/

XCUITest con Fastlane

# Fastfile
default_platform(:ios)

platform :ios do
  desc "Ejecutar 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 "Ejecutar XCUITests en múltiples dispositivos"
  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: Seleccionar versión de Xcode
        run: sudo xcode-select -s /Applications/Xcode_14.3.app

      - name: Instalar dependencias
        run: |
          gem install bundler
          bundle install

      - name: Ejecutar XCUITests
        run: |
          xcodebuild test \
            -scheme MyApp \
            -destination 'platform=iOS Simulator,name=iPhone 14 Pro,OS=16.4' \
            -resultBundlePath TestResults.xcresult \
            -enableCodeCoverage YES

      - name: Subir resultados de pruebas
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: xcuitest-results
          path: TestResults.xcresult

Mejores Prácticas

Page Object Model en 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)))
    }
}

// Uso
@Test
fun testLoginFlow() {
    LoginPage()
        .enterEmail("user@example.com")
        .enterPassword("password123")
        .clickLogin()
        .verifyDashboardDisplayed()
}

Page Object Model en 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)
    }
}

// Uso
func testLoginFlow() {
    LoginPage(app: app)
        .enterEmail("user@example.com")
        .enterPassword("password123")
        .tapLogin()
        .verifyDashboardDisplayed()
}

Conclusión

Espresso y XCUITest proporcionan rendimiento y confiabilidad inigualables para testing móvil específico de plataforma. El modelo de ejecución in-process de Espresso ofrece velocidad excepcional para testing de Android, mientras que la integración de XCUITest con Xcode proporciona herramientas comprehensivas para desarrollo iOS.

Para equipos comprometidos con el desarrollo móvil nativo, dominar estos frameworks permite la creación de suites de pruebas robustas, rápidas y mantenibles que aprovechan las capacidades únicas de cada plataforma. La inversión en experiencia específica de plataforma rinde dividendos en confiabilidad de pruebas y velocidad de ejecución comparado con alternativas cross-platform.