Page Object Model en Appium

LoginTest refactorizado a POM: BasePage, ProductsPage, LoginPage. Locators centralizados, navegación sin duplicar. Mismos 4 tests, mejor código.

Diagrama de arquitectura POM en Appium mostrando LoginTest sin locators conectado a ProductsPage y LoginPage que heredan de BasePage con driver y wait
Page Object Model: el test usa pages, las pages heredan de BasePage. 0 locators en los tests.

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

Estructura de proyecto Appium en IntelliJ con carpeta pages conteniendo BasePage LoginPage ProductsPage y carpeta tests con LoginTest y PrimerTest
Estructura final: pages en src/main/java, tests en src/test/java. Cada page tiene su responsabilidad.
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

Codigo de ProductsPage en IntelliJ mostrando locators como constantes y metodos obtenerTitulo irAlLogin scrollHastaProducto y obtenerTextoProducto
ProductsPage: 50 líneas. Menú, título, scroll y navegación al login centralizados en una sola clase.

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 un LoginPage. Esto centraliza la navegación: los tests no saben que hay un menú hamburguesa.
  • scrollHastaProducto() — usa UiScrollable para 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

Codigo de LoginPage en IntelliJ con locators de campos de login y metodos ingresarUsername ingresarPassword tapLogin y tapLoginEsperandoError
LoginPage: campos, acciones y errores. tapLogin para login exitoso, tapLoginEsperandoError para validar fallos.

Qué hace LoginPage:

  • ingresarUsername(), ingresarPassword() — escriben en los campos. Devuelven this (la misma LoginPage), lo que permite encadenar: loginPage.ingresarUsername("bob").ingresarPassword("123").
  • limpiarUsername() — hace clear() 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. Devuelve ProductsPage. 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). Devuelve this porque 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

LoginTest en IntelliJ mostrando imports de pages setUp con UiAutomator2Options tearDown y test loginExitoso usando productsPage e irAlLogin
LoginTest refactorizado: los imports son de pages, no de Appium. El test habla en lenguaje de negocio.
LoginTest en IntelliJ mostrando tests scrollHastaProducto loginConClearYReingreso y loginConCamposVacios usando metodos de ProductsPage y LoginPage
Tres tests sin un solo locator. Solo métodos de pages y assertions.

Qué cambió del post anterior:

  • Ningún locator en el test. Cero AppiumBy, cero resource-id, cero accessibilityId.
  • La navegación al login está en un solo lugar: productsPage.irAlLogin().
  • El test habla en lenguaje de negocio: ingresarCredenciales, tapLogin, obtenerErrorUsername.
  • El setUp() crea productsPage directamente. Es la pantalla donde arranca la app.
  • Los imports son de pages.LoginPage y pages.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

Consola de IntelliJ mostrando 4 tests pasados en LoginTest con tiempos individuales total 1 min 14 sec y exit code 0
4 tests verdes en primer intento. Mismos tests del post anterior, diferente estructura.
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.

Menu lateral de My Demo App en Appium Inspector mostrando Catalog WebView QR Code Scanner Geo Location Drawing About y Log Out
Menú lateral completo. Hay más pantallas para explorar en posts futuros.

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:

  1. Products — la pantalla inicial con el catálogo
  2. Product Detail — detalle de un producto, con selector de color y botón "Add to Cart"
  3. Cart — carrito con items agregados
  4. Login — formulario de credenciales
  5. Checkout: Shipping — dirección de envío
  6. Checkout: Payment — datos de pago
  7. Review Order — resumen antes de confirmar
  8. Checkout Complete — confirmación de compra
  9. Sort dialog — diálogo para ordenar productos
  10. Menú lateral — navegación general de la app
Pantalla Checkout Complete de My Demo App mostrando mensaje Thank you for your order y boton Continue Shopping
Checkout Complete: última pantalla del circuito de compra. El botón Continue Shopping vuelve a Products.

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