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ística | Espresso | XCUITest |
---|---|---|
Sincronización | Automática (hilo UI) | Esperas manuales requeridas |
Velocidad | Muy rápida (in-process) | Moderada (out-of-process) |
Inestabilidad | Baja | Media |
Curva de aprendizaje | Moderada | Moderada |
Integración IDE | Android Studio | Solo Xcode |
Soporte CI/CD | Excelente | Excelente |
Mocking de red | Via OkHttp Interceptor | Via URLProtocol |
Testing de accesibilidad | espresso-accessibility | Integrado |
Soporte de screenshots | Implementación manual | XCTAttachment 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.