Page Object Model en Appium
LoginTest refactorizado a POM: BasePage, ProductsPage, LoginPage. Locators centralizados, navegación sin duplicar. Mismos 4 tests, mejor código.
El problema
El LoginTest del post anterior funcionaba. 4 tests pasando, assertions correctas, waits explícitos. Pero todo estaba en un solo archivo.
Los locators se repetían. La navegación al login estaba copiada en 3 tests. Si mañana Sauce Labs cambiaba el resource-id del campo de username, tenía que buscarlo y reemplazarlo en cada test que lo usara.
Con 4 tests se nota poco. Con 40, es insostenible.
Qué es POM
Page Object Model separa dos cosas que no deberían estar juntas: la interacción con la UI y la lógica del test.
Cada pantalla de la app se convierte en una clase Java (una "page"). Esa clase tiene los locators y los métodos para interactuar con esa pantalla. El test usa esos métodos sin saber qué resource-id tiene un botón ni cómo se espera a que aparezca.
En la serie de Selenium hice lo mismo. La diferencia acá es que los locators son de mobile (AppiumBy.id, AppiumBy.accessibilityId) y la navegación incluye menú hamburguesa y scroll con UiScrollable.
Tabla de responsabilidades:
| Page | Test |
|---|---|
| Sabe qué locators tiene cada pantalla | No conoce ningún locator |
| Sabe cómo interactuar con la UI | Solo dice qué quiere hacer |
| Maneja los waits | No sabe cómo se espera |
| Devuelve la siguiente page | Recibe la page y la usa |
Estructura del proyecto

appium-java-framework/
├── src/
│ ├── main/java/
│ │ └── pages/
│ │ ├── BasePage.java
│ │ ├── LoginPage.java
│ │ └── ProductsPage.java
│ └── test/java/
│ └── tests/
│ ├── LoginTest.java
│ └── PrimerTest.java
├── apk/
│ └── mda-2.2.0-25.apk
└── pom.xml
Las pages van en src/main/java/pages/. Los tests en src/test/java/tests/.
No es arbitrario. Las pages son código de producción del framework: se reutilizan, se extienden, se importan desde cualquier test. Los tests son los que corren con TestNG. Esa separación es estándar en frameworks de automation.
Las 3 pages
BasePage: lo que comparten todas las páginas
package pages;
import io.appium.java_client.android.AndroidDriver;
import org.openqa.selenium.support.ui.WebDriverWait;
public class BasePage {
protected AndroidDriver driver;
protected WebDriverWait wait;
public BasePage(AndroidDriver driver, WebDriverWait wait) {
this.driver = driver;
this.wait = wait;
}
}
15 líneas. driver y wait son protected: accesibles desde cualquier page que herede de BasePage, pero no desde los tests.
El constructor recibe ambos. Cada page que extienda BasePage los tiene disponibles sin declararlos de nuevo.
Por ahora BasePage no tiene métodos utilitarios (click(), type(), etc.). Los voy a agregar cuando la repetición en las pages lo justifique. No antes.
ProductsPage: la pantalla inicial

Qué hace ProductsPage:
obtenerTitulo()— espera a que el título sea visible y devuelve su texto. Se usa para validar que estamos en Products.irAlLogin()— abre el menú hamburguesa, espera a que aparezca "Login Menu Item", lo toca, y espera a que cargue la pantalla de login. Devuelve unLoginPage. Esto centraliza la navegación: los tests no saben que hay un menú hamburguesa.scrollHastaProducto()— usaUiScrollablepara hacer scroll hasta encontrar un producto por texto.obtenerTextoProducto()— busca un elemento por texto exacto y lo devuelve.
El detalle del return new LoginPage(driver, wait) es clave. El método no devuelve void: devuelve la page donde terminás después de la acción. Eso permite encadenar: productsPage.irAlLogin().ingresarCredenciales(...).
Los locators están como private static final String. Constantes, privadas, en un solo lugar. Si Sauce Labs cambia un accessibilityId, lo cambio acá y todos los tests siguen funcionando.
LoginPage: formulario, acciones y errores

Qué hace LoginPage:
ingresarUsername(),ingresarPassword()— escriben en los campos. Devuelventhis(la misma LoginPage), lo que permite encadenar:loginPage.ingresarUsername("bob").ingresarPassword("123").limpiarUsername()— haceclear()en el campo. Lo uso en el test de clear + reingreso.ingresarCredenciales()— método de conveniencia que llama a los dos anteriores. Para cuando no necesitás hacer nada entre username y password.tapLogin()— toca el botón y espera a que aparezca la pantalla de Products. DevuelveProductsPage. Se usa cuando el login debería ser exitoso.tapLoginEsperandoError()— toca el botón pero NO espera Products. Se usa cuando el login debería fallar (campos vacíos, credenciales incorrectas). Devuelvethisporque seguís en la misma pantalla.obtenerErrorUsername()— lee el texto del error de username.
La separación entre tapLogin() y tapLoginEsperandoError() no es caprichosa. Si uso tapLogin() con campos vacíos, el wait.until(...) que espera "Products" nunca va a resolver y el test va a dar timeout después de 10 segundos. Son dos flujos distintos, dos métodos distintos.
LoginTest refactorizado


Qué cambió del post anterior:
- Ningún locator en el test. Cero
AppiumBy, ceroresource-id, ceroaccessibilityId. - La navegación al login está en un solo lugar:
productsPage.irAlLogin(). - El test habla en lenguaje de negocio:
ingresarCredenciales,tapLogin,obtenerErrorUsername. - El
setUp()creaproductsPagedirectamente. Es la pantalla donde arranca la app. - Los imports son de
pages.LoginPageypages.ProductsPage. No de Appium.
Comparación rápida del test loginExitoso:
Antes:
// Abrir menú
driver.findElement(AppiumBy.accessibilityId("View menu")).click();
wait.until(ExpectedConditions.visibilityOfElementLocated(
AppiumBy.accessibilityId("Login Menu Item")));
driver.findElement(AppiumBy.accessibilityId("Login Menu Item")).click();
wait.until(ExpectedConditions.visibilityOfElementLocated(
AppiumBy.id("com.saucelabs.mydemoapp.android:id/nameET")));
// Login
driver.findElement(AppiumBy.id("com.saucelabs.mydemoapp.android:id/nameET"))
.sendKeys("[email protected]");
driver.findElement(AppiumBy.id("com.saucelabs.mydemoapp.android:id/passwordET"))
.sendKeys("10203040");
driver.findElement(AppiumBy.accessibilityId("Tap to login with given credentials")).click();
// Assert
wait.until(ExpectedConditions.visibilityOfElementLocated(
AppiumBy.accessibilityId("title")));
String titulo = driver.findElement(AppiumBy.accessibilityId("title")).getText();
Assert.assertEquals(titulo, "Products");
Después (actual Post):
LoginPage loginPage = productsPage.irAlLogin();
loginPage.ingresarCredenciales("[email protected]", "10203040");
ProductsPage resultado = loginPage.tapLogin();
String titulo = resultado.obtenerTitulo();
Assert.assertEquals(titulo, "Products",
"No volvió a la pantalla de Products después del login");
El test pasó de ~15 líneas con locators a 5 líneas que se leen como instrucciones.
4 tests verdes

Tests passed: 4 of 4 tests – 1 min 14 sec
loginConCamposVacios 4 sec 580 ms
loginConClearYReingreso 7 sec 286 ms
loginExitoso 6 sec 321 ms
scrollHastaProducto 15 sec 51 ms
Process finished with exit code 0
Mismos 4 tests que en el post anterior. Mismos assertions. Los tiempos son similares: POM no afecta performance, solo organiza el código.
Los 4 pasaron en el primer intento. No tuve errores de implementación. Esto probablemente se debe a que ya tenía el POM muy claro de haberlo implementado en la serie de Selenium. El patrón es el mismo; lo que cambia son los locators (de Selenium By a Appium AppiumBy).
Exploración con Inspector: las 10 pantallas
Después de implementar POM, dediqué tiempo a explorar la app completa con Appium Inspector. Navegué manualmente por todo el circuito de compra y mapeé los locators de cada pantalla.

La app tiene más de lo que usé hasta ahora. El menú lateral muestra: Catalog, WebView, QR Code Scanner, Geo Location, Drawing, About, Reset App State, FingerPrint, Virtual USB, Crash app (debug), Log Out.
Las 10 pantallas que identifiqué en el flujo de compra:
- Products — la pantalla inicial con el catálogo
- Product Detail — detalle de un producto, con selector de color y botón "Add to Cart"
- Cart — carrito con items agregados
- Login — formulario de credenciales
- Checkout: Shipping — dirección de envío
- Checkout: Payment — datos de pago
- Review Order — resumen antes de confirmar
- Checkout Complete — confirmación de compra
- Sort dialog — diálogo para ordenar productos
- Menú lateral — navegación general de la app

Cada pantalla va a tener su propia page en el futuro. Pero no las creo ahora. Las creo cuando escriba los tests que las necesiten.
La estructura final del proyecto va a quedar así:
pages/
├── BasePage.java
├── ProductsPage.java ✅
├── LoginPage.java ✅
├── ProductDetailPage.java
├── CartPage.java
├── CheckoutShippingPage.java
├── CheckoutPaymentPage.java
├── ReviewOrderPage.java
└── CheckoutCompletePage.java
Por ahora, 3 pages. Las otras 6 llegan cuando las necesite.
Estado actual
- 3 pages: BasePage, ProductsPage, LoginPage
- LoginTest refactorizado: 4 tests usando pages
- 0 locators en los tests
- Navegación centralizada en
productsPage.irAlLogin() - 10 pantallas exploradas con Inspector, locators mapeados
- BasePage sin métodos utilitarios todavía (no los necesité aún)
Repo: github.com/cesarbeassuarez/appium-java-framework