Product Detail y Cart en Appium: del catálogo al carrito

ProductDetailPage y CartPage: seleccionar producto, cantidad, color, agregar al carrito, verificar totales, eliminar. 8 tests, 3 errores reales.

Diagrama del flujo Products a Product Detail a My Cart mostrando tres pantallas con hallazgos texto no clickable Add to cart no navega y app crashea con mayoría de productos
El flujo completo del Post: 3 pantallas, 2 pages nuevas, 8 tests, 3 errores reales y un bug de la app demo.

CONTEXTO

En el post anterior Page Object Model en Appium implementé POM: BasePage, ProductsPage, LoginPage. 4 tests de login sin un solo locator en los tests.

Ahora toca el flujo de compra: entrar a un producto, verificar datos, agregar al carrito, verificar totales, eliminar. Son 2 pages nuevas (ProductDetailPage, CartPage) y 8 tests.


El primer error: el setUp

Copié ProductDetailTest desde cero y escribí el setUp con las capabilities básicas. Error: SessionNotCreatedException: Cannot start the application. La app abría pero Appium no podía conectarse a la sesión.

El problema: me faltaban dos capabilities que sí tenía en LoginTest.

// Lo que escribí (no funciona)
options.setApp(System.getProperty("user.dir") + "/apk/mda-2.2.0-25.apk");
options.setAutoGrantPermissions(true);

// Lo que funciona (copiado de LoginTest)
options.setApp(new File("apk/mda-2.2.0-25.apk").getAbsolutePath());
options.setAutomationName("UiAutomator2");
options.setNewCommandTimeout(Duration.ofSeconds(120));
options.setCapability("appium:appWaitActivity", "*");

appWaitActivity: "*" es clave — sin eso Appium no sabe qué activity esperar y la sesión falla. Lección: no escribir el setUp de memoria, copiarlo del test que funciona.


El texto no es clickable

Comparación en Appium Inspector mostrando texto Sauce Labs Backpack con clickable false a la izquierda e imagen Product Image con clickable true a la derecha
Izquierda: el texto tiene clickable: false. Derecha: la imagen tiene clickable: true. Solo la imagen navega al detalle.

Primer intento de entrar al detalle del producto: busqué el texto "Sauce Labs Backpack" con UiSelector().text(...), hice .click(). El test obtenía "Products" en vez de "Sauce Labs Backpack" — no navegó.

Fui al Inspector. Atributos del texto:

text: "Sauce Labs Backpack"
clickable: false

Atributos de la imagen:

content-desc: "Product Image"
clickable: true

En web, normalmente todo el card es clickable. Acá no. Solo la imagen navega al detalle. El texto es solo display.

El problema adicional: todas las imágenes tienen el mismo content-desc="Product Image". No puedo hacer accessibilityId("Product Image") porque tocaría la primera que encuentre.

La solución fue XPath: encontrar el texto, subir al padre (el card ViewGroup), y desde ahí bajar a la imagen:

driver.findElement(AppiumBy.xpath(
        "//android.widget.TextView[@text='" + nombreProducto + "']" +
        "/parent::android.view.ViewGroup" +
        "/android.widget.ImageView[@content-desc='Product Image']"
)).click();

XPath no es ideal (es más frágil que accessibilityId), pero es la única forma de tocar la imagen correcta cuando todas comparten el mismo content-desc. En la serie de Selenium no tuve que hacer esto — el sitio web tiene IDs únicos por producto.


ProductDetailPage

Pantalla de detalle de Sauce Labs Backpack mostrando precio 29.99 cuatro colores cantidad 1 y boton Add to cart
Product Detail: nombre, precio, 4 colores, cantidad con +/- y "Add to cart".

Después de resolver la navegación, el detalle del producto tiene todo lo que necesito:

public class ProductDetailPage extends BasePage {

    private static final String PRODUCT_NAME = "com.saucelabs.mydemoapp.android:id/productTV";
    private static final String PRODUCT_PRICE = "com.saucelabs.mydemoapp.android:id/priceTV";
    private static final String QUANTITY = "com.saucelabs.mydemoapp.android:id/noTV";
    private static final String PLUS_BUTTON_ACC = "Increase item quantity";
    private static final String MINUS_BUTTON_ACC = "Decrease item quantity";
    private static final String ADD_TO_CART_ACC = "Tap to add product to cart";
    // ...
}

Los métodos: obtenerNombreProducto(), obtenerPrecio(), obtenerCantidad(), aumentarCantidad(), disminuirCantidad(), seleccionarColor(), agregarAlCarrito(), irAlCarrito(), obtenerBadgeCarrito(), volverAProducts().

Un detalle de los locators: la pantalla comparte resource-id con la pantalla de Products. productTV en Products contiene "Products" (el título). En Product Detail contiene "Sauce Labs Backpack" (el nombre del producto). Mismo ID, contenido distinto según la pantalla.

Eso provocó un falso positivo en el primer test: el wait.until resolvía con el productTV de Products antes de que cargara la pantalla de detalle. Lo resolví esperando un elemento exclusivo de Product Detail:

// En seleccionarProducto(), después del click:
wait.until(ExpectedConditions.visibilityOfElementLocated(
        AppiumBy.accessibilityId("Tap to add product to cart")
));

"Add to cart" solo existe en Product Detail. Si es visible, ya estamos en la pantalla correcta.


Add to cart no navega al carrito

Pantalla de detalle de Sauce Labs Backpack con badge 1 en icono de carrito indicando producto agregado
Después de "Add to cart": se queda en el detalle. El badge pasa de vacío a 1.

Esperaba que "Add to cart" llevara a la pantalla del carrito. No. Se queda en el detalle del producto. Lo que cambia es el badge del ícono del carrito arriba a la derecha: pasa de vacío a "1".

Eso cambió la arquitectura de agregarAlCarrito(). En vez de devolver CartPage, devuelve this:

public ProductDetailPage agregarAlCarrito() {
    driver.findElement(AppiumBy.accessibilityId(ADD_TO_CART_ACC)).click();
    return this;
}

Para ir al carrito hay un método separado:

public CartPage irAlCarrito() {
    driver.findElement(AppiumBy.accessibilityId("View cart")).click();
    wait.until(ExpectedConditions.visibilityOfElementLocated(
            AppiumBy.id("com.saucelabs.mydemoapp.android:id/totalPriceTV")
    ));
    return new CartPage(driver, wait);
}

Y para verificar que se agregó:

public String obtenerBadgeCarrito() {
    return driver.findElement(
            AppiumBy.id("com.saucelabs.mydemoapp.android:id/cartTV")
    ).getText();
}

El test de agregar al carrito queda así:

@Test
public void agregarAlCarrito() {
    ProductDetailPage detailPage = productsPage.seleccionarProducto("Sauce Labs Backpack");

    detailPage.agregarAlCarrito();
    Assert.assertEquals(detailPage.obtenerBadgeCarrito(), "1");

    detailPage.agregarAlCarrito();
    Assert.assertEquals(detailPage.obtenerBadgeCarrito(), "2");
}

Dos llamadas a agregarAlCarrito(), badge pasa de 1 a 2. El test verifica que cada adición se refleja.


CartPage

Pantalla My Cart mostrando Sauce Labs Backpack precio 29.99 color negro cantidad 1 Remove Item total 1 Items y Proceed To Checkout
Cart: producto, precio, color, cantidad, "Remove Item" y total. Todo verificable con assertions.
public class CartPage extends BasePage {

    private static final String TITLE = "com.saucelabs.mydemoapp.android:id/productTV";
    private static final String ITEM_NAME = "com.saucelabs.mydemoapp.android:id/titleTV";
    private static final String ITEM_PRICE = "com.saucelabs.mydemoapp.android:id/priceTV";
    private static final String ITEM_QUANTITY = "com.saucelabs.mydemoapp.android:id/noTV";
    private static final String TOTAL_ITEMS = "com.saucelabs.mydemoapp.android:id/itemsTV";
    private static final String TOTAL_PRICE = "com.saucelabs.mydemoapp.android:id/totalPriceTV";
    private static final String REMOVE_ITEM_ACC = "Removes product from cart";
    private static final String NO_ITEMS_TITLE = "com.saucelabs.mydemoapp.android:id/noItemTitleTV";
    // ...
}

Dato: productTV aparece en 3 pantallas (Products, Product Detail, Cart) con contenido distinto en cada una. En Cart muestra "My Cart".

El test más completo del carrito:

@Test
public void verificarProductoEnCarrito() {
    ProductDetailPage detailPage = productsPage.seleccionarProducto("Sauce Labs Backpack");
    detailPage.agregarAlCarrito();
    CartPage cartPage = detailPage.irAlCarrito();

    Assert.assertEquals(cartPage.obtenerNombreProducto(), "Sauce Labs Backpack");
    Assert.assertEquals(cartPage.obtenerPrecioProducto(), "$ 29.99");
    Assert.assertEquals(cartPage.obtenerCantidad(), "1");
    Assert.assertEquals(cartPage.obtenerTotalItems(), "1 Items");
    Assert.assertEquals(cartPage.obtenerTotalPrecio(), "$ 29.99");
}

5 assertions en un test. Nombre, precio, cantidad, total items, total precio.


Eliminar del carrito

Pantalla de carrito vacio con titulo No Items icono de carrito vacio mensaje y boton Go Shopping
Después de "Remove Item": carrito vacío. Tardó 6 segundos en cargar.

Después de tocar "Remove Item", la app tarda unos 6 segundos en mostrar la pantalla vacía. Sin un wait.until, el test leería el estado anterior o lanzaría excepción.

El carrito vacío muestra: título "No Items", ícono de carrito vacío, mensaje "Oh no! Your cart is empty..." y botón "Go Shopping".

public String obtenerTituloCarritoVacio() {
    return wait.until(ExpectedConditions.visibilityOfElementLocated(
            AppiumBy.id(NO_ITEMS_TITLE)
    )).getText();
}
@Test
public void eliminarProductoDelCarrito() {
    ProductDetailPage detailPage = productsPage.seleccionarProducto("Sauce Labs Backpack");
    detailPage.agregarAlCarrito();
    CartPage cartPage = detailPage.irAlCarrito();

    Assert.assertEquals(cartPage.obtenerTotalItems(), "1 Items");

    cartPage.eliminarProducto();
    Assert.assertEquals(cartPage.obtenerTituloCarritoVacio(), "No Items");
}

Cantidad múltiple

Para verificar que la app calcula bien el total con cantidad mayor a 1:

@Test
public void verificarCantidadMultipleEnCarrito() {
    ProductDetailPage detailPage = productsPage.seleccionarProducto("Sauce Labs Backpack");
    detailPage.aumentarCantidad(); // cantidad = 2
    detailPage.agregarAlCarrito();
    CartPage cartPage = detailPage.irAlCarrito();

    Assert.assertEquals(cartPage.obtenerCantidad(), "2");
    Assert.assertEquals(cartPage.obtenerTotalItems(), "2 Items");
    Assert.assertEquals(cartPage.obtenerTotalPrecio(), "$ 59.98");
}

$29.99 × 2 = $59.98. El test verifica que la app multiplica bien.


La app crashea con casi todos los productos

Quería un test que agregara dos productos distintos al carrito. Elegí "Sauce Labs Bike Light" como segundo producto.

La app se cerró.

Probé manualmente con varios productos: Sauce Labs Bike Light, Bolt T-Shirt, Onesie. Todos crashean al entrar al detalle. Solo las variantes de "Sauce Labs Backpack" funcionan (excepto la green, que también crashea).

Esto es un bug de la app demo, no del framework. Lo documenté pero no voy a reportarlo — es una app de práctica, no un producto en producción. Lo que sí hice fue adaptar el test para usar dos variantes de Backpack que funcionan:

@Test
public void agregarDosProductosDistintos() {
    ProductDetailPage detailPage = productsPage.seleccionarProducto("Sauce Labs Backpack");
    detailPage.agregarAlCarrito();
    ProductsPage products = detailPage.volverAProducts();

    detailPage = products.seleccionarProducto("Sauce Labs Backpack (yellow)");
    detailPage.agregarAlCarrito();
    CartPage cartPage = detailPage.irAlCarrito();

    Assert.assertEquals(cartPage.obtenerTotalItems(), "2 Items");
    Assert.assertEquals(cartPage.obtenerTotalPrecio(), "$ 59.98");
}

volverAProducts() usa driver.navigate().back() — equivalente al botón "atrás" de Android. Funciona porque la app mantiene la navegación en stack.


12 tests verdes

Consola de IntelliJ mostrando 12 tests pasados en 6 minutos 33 segundos con CartTest LoginTest y ProductDetailTest
12 de 12. 4 de Cart, 4 de Login, 4 de Product Detail. Todo el framework hasta acá.
Tests passed: 12 of 12 tests – 6 min 33 sec

CartTest                    2 min 20 sec
  agregarDosProductosDistintos    11 sec
  eliminarProductoDelCarrito       9 sec
  verificarCantidadMultiple        5 sec
  verificarProductoEnCarrito       5 sec

LoginTest                   2 min 14 sec
  loginConCamposVacios             4 sec
  loginConClearYReingreso          7 sec
  loginExitoso                     5 sec
  scrollHastaProducto             12 sec

ProductDetailTest           1 min 59 sec
  agregarAlCarrito                 4 sec
  modificarCantidad                5 sec
  seleccionarColor                 3 sec
  verificarDetalleProducto         2 sec

Estructura actual

appium-java-framework/
├── src/
│   ├── main/java/
│   │   └── pages/
│   │       ├── BasePage.java
│   │       ├── CartPage.java           ← nuevo
│   │       ├── LoginPage.java
│   │       ├── ProductDetailPage.java  ← nuevo
│   │       └── ProductsPage.java
│   └── test/java/
│       └── tests/
│           ├── CartTest.java           ← nuevo
│           ├── LoginTest.java
│           └── ProductDetailTest.java  ← nuevo
├── apk/
│   └── mda-2.2.0-25.apk
└── pom.xml

5 pages, 3 test classes, 12 tests. Cada pantalla tiene su page, cada flujo tiene sus tests.


Errores del Post 8

Error Causa Solución
SessionNotCreatedException Faltaba appWaitActivity: "*" en el setUp Copiar capabilities de LoginTest
Expected "Sauce Labs Backpack", actual "Products" productTV existe en Products y en Detail, el wait resolvió con el de Products Esperar elemento exclusivo de Detail ("Tap to add product to cart")
Timeout esperando "Add to cart" El texto del producto no es clickable, solo la imagen XPath: subir al padre → buscar ImageView hijo
App crashea con Sauce Labs Bike Light Bug de la app demo Usar variantes de Backpack para tests de dos productos

Código de tests

Código de ProductDetailTest en IntelliJ mostrando imports de pages setUp con capabilities de Appium y tearDown
ProductDetailTest: setUp idéntico a LoginTest. Los imports son de pages, no de Appium.
Cuatro tests en ProductDetailTest verificando detalle cantidad color y badge del carrito sin ningun locator visible
4 tests, 0 locators. verificarDetalleProducto, modificarCantidad, seleccionarColor, agregarAlCarrito.
Cuatro tests en LoginTest mostrando loginExitoso scrollHastaProducto loginConClearYReingreso y loginConCamposVacios con assertions
LoginTest completo: 4 tests del Post 7. El mismo patrón — todo pasa por métodos de pages, 0 locators en los tests.
Cuatro tests en CartTest verificando producto en carrito eliminacion cantidad multiple y dos productos distintos
4 tests de carrito. El más largo: seleccionar, agregar, volver, seleccionar otro, agregar, ir al carrito, verificar.

Estado actual

  • 5 pages: BasePage, ProductsPage,
  • LoginPage, ProductDetailPage, CartPage
  • 12 tests: 4 login, 4 product detail, 4 cart
  • Flujo cubierto: catálogo → detalle → agregar → carrito → verificar → eliminar
  • Bug documentado: la app crashea con la mayoría de productos
  • Próximo post: checkout completo (Shipping → Payment → Review → Confirmación)

Repo: github.com/cesarbeassuarez/appium-java-framework