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

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

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

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

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

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

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




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)