W3C Actions en Appium: gestos programáticos con Drawing
DrawingPage con W3C Actions para dibujar en canvas, menú lateral, permisos del sistema, Inspector Gestures y Recorder. 19 tests, 10 pages.
CONTEXTO
Hasta el post anterior Checkout completo en Appium: del carrito a la confirmación de compra, tenía 16 tests que cubrían el flujo de compra completo: catálogo, detalle, carrito, checkout. Todos usando findElement().click(), sendKeys(), UiScrollable.
Pero hay interacciones en mobile que no se resuelven con clicks ni con scroll: dibujar en un canvas, hacer swipe con control de velocidad, arrastrar elementos. Para eso existen las W3C Actions.
My Demo App tiene una pantalla "Drawing" con un canvas donde podés dibujar con el dedo. Es el caso de uso perfecto: no hay un botón que hacer click, no hay un campo donde escribir. Hay que simular el dedo tocando la pantalla, moviéndose, y soltando.
Por qué W3C Actions y no TouchAction
TouchAction fue la API de gestos de Appium durante años. Pero está deprecated desde Appium 2.x. El reemplazo es W3C Actions, que es el estándar del W3C WebDriver.
La diferencia:
// TouchAction (deprecated)
new TouchAction(driver)
.press(point(200, 600))
.moveTo(point(800, 1200))
.release()
.perform();
// W3C Actions (actual)
PointerInput finger = new PointerInput(PointerInput.Kind.TOUCH, "finger");
Sequence trazo = new Sequence(finger, 1);
trazo.addAction(finger.createPointerMove(Duration.ZERO,
PointerInput.Origin.viewport(), 200, 600));
trazo.addAction(finger.createPointerDown(PointerInput.MouseButton.LEFT.asArg()));
trazo.addAction(finger.createPointerMove(Duration.ofMillis(1000),
PointerInput.Origin.viewport(), 800, 1200));
trazo.addAction(finger.createPointerUp(PointerInput.MouseButton.LEFT.asArg()));
driver.perform(Arrays.asList(trazo));
Más verboso, sí. Pero es el estándar actual y no va a desaparecer. TouchAction puede dejar de funcionar en cualquier actualización de Appium.
W3C Actions vs UiScrollable
Ya usaba UiScrollable para scroll en el catálogo:
driver.findElement(AppiumBy.androidUIAutomator(
"new UiScrollable(new UiSelector().scrollable(true))" +
".scrollTextIntoView(\"Sauce Labs Backpack (yellow)\")"
));
¿Cuándo usar cada uno?
UiScrollable: cuando necesitás llegar a un elemento por texto. Android lo busca solo, scrollea las veces que haga falta. No controlás coordenadas ni velocidad.
W3C Actions: cuando necesitás control total del gesto. Definís exactamente dónde empieza el dedo, dónde termina, y cuánto tarda. Sirve para dibujar, arrastrar, swipe con velocidad específica, gestos complejos.
En este post uso W3C Actions para dibujar en el canvas de Drawing. UiScrollable no sirve ahí — no hay texto que buscar ni scroll que hacer. Hay que simular un dedo moviéndose por la pantalla.
Explorando Drawing con Appium Inspector
Antes de codear necesitaba los locators de la pantalla Drawing. Nunca la había explorado con Inspector.
El menú lateral

El menú se abre con el ícono hamburguesa (accessibilityId: "View menu"). Cada item del menú tiene el mismo resource-id: com.saucelabs.mydemoapp.android:id/itemTV. Para seleccionar "Drawing" hay que buscar por texto entre todos los items.
La pantalla Drawing

Locators capturados:
| Elemento | Estrategia | Selector |
|---|---|---|
| Título | id | drawingTV |
| Canvas | id | signature_pad |
| Clear | id | clearBtn |
| Save | id | saveBtn |
Los bounds del canvas son enormes: ~1000×1670 px. Coordenadas seguras para dibujar: entre (200, 600) y (800, 1200).
Un detalle: el canvas no cambia ningún atributo cuando tiene un dibujo. Inspector muestra los mismos valores antes y después de dibujar. Eso significa que no puedo hacer assertion sobre el canvas directamente. La assertion va por el flujo completo: dibujar → Save → verificar mensaje de confirmación.
Save con canvas vacío
Probé tocar Save sin dibujar nada. Resultado: aparece el mismo mensaje "Drawing saved successfully to gallery". La app no distingue entre un canvas vacío y uno con dibujo. Eso limita las assertions posibles, pero para un post de gestos el valor está en la técnica del gesto, no en la lógica de negocio.
Appium Inspector: Gestures y Recorder
Descubrí dos pestañas de Inspector que no había usado:
Gestures — editor visual de gestos

El Gesture Builder permite armar gestos W3C Actions de forma visual. Definís los puntos de inicio y fin, la duración, y lo ejecutás directamente en el dispositivo. La línea se dibujó en la app real.
Dato que descubrí: W3C Actions trabaja a nivel del sistema operativo, no de la app. Las coordenadas son absolutas del viewport. Si las coordenadas caen fuera de la app, Android responde con gestos del sistema. Por eso es crítico calcular bien los bounds del canvas.
Recorder — grabador de acciones

El Recorder graba las acciones que hacés en el dispositivo y genera código en Java (o Python, JS). El código que generó para el swipe:
final var finger = new PointerInput(PointerInput.Kind.TOUCH, "finger");
var start = new Point(212, 742);
var end = new Point(725, 1624);
var swipe = new Sequence(finger, 1);
swipe.addAction(finger.createPointerMove(Duration.ofMillis(0),
PointerInput.Origin.viewport(), start.getX(), start.getY()));
swipe.addAction(finger.createPointerDown(PointerInput.MouseButton.LEFT.asArg()));
swipe.addAction(finger.createPointerMove(Duration.ofMillis(1000),
PointerInput.Origin.viewport(), end.getX(), end.getY()));
swipe.addAction(finger.createPointerUp(PointerInput.MouseButton.LEFT.asArg()));
driver.perform(Arrays.asList(swipe));
Usé este código como referencia para mi DrawingPage. La diferencia: el Recorder genera taps por coordenadas para todo (Save, Clear). En mi framework uso findElement().click() para botones — es más robusto y legible. W3C Actions las reservé para el canvas, que es donde realmente las necesito.
swipe() en BasePage
Agregué un método genérico de swipe en BasePage. W3C Actions reutilizable:
public void swipe(int startX, int startY, int endX, int endY, int durationMs) {
PointerInput finger = new PointerInput(PointerInput.Kind.TOUCH, "finger");
Sequence swipe = new Sequence(finger, 1);
swipe.addAction(finger.createPointerMove(Duration.ZERO,
PointerInput.Origin.viewport(), startX, startY));
swipe.addAction(finger.createPointerDown(PointerInput.MouseButton.LEFT.asArg()));
swipe.addAction(finger.createPointerMove(Duration.ofMillis(durationMs),
PointerInput.Origin.viewport(), endX, endY));
swipe.addAction(finger.createPointerUp(PointerInput.MouseButton.LEFT.asArg()));
driver.perform(Arrays.asList(swipe));
}
La secuencia es siempre la misma: mover el puntero al punto inicial → bajar el dedo → mover al punto final (con duración) → soltar. durationMs controla la velocidad del gesto.
Imports nuevos en BasePage:
import org.openqa.selenium.interactions.PointerInput;
import org.openqa.selenium.interactions.Sequence;
import java.time.Duration;
import java.util.Arrays;
Navegación al menú lateral
Hasta ahora toda la navegación era por taps en elementos de la app: productos, botones, links. Drawing requiere abrir el menú lateral — algo nuevo en el framework.
Agregué abrirMenu() e irADrawing() en ProductsPage:
private static final String MENU_ITEM_ID = "com.saucelabs.mydemoapp.android:id/itemTV";
public void abrirMenu() {
driver.findElement(AppiumBy.accessibilityId("View menu")).click();
wait.until(ExpectedConditions.visibilityOfElementLocated(
AppiumBy.id(MENU_ITEM_ID)
));
}
public DrawingPage irADrawing() {
abrirMenu();
driver.findElements(AppiumBy.id(MENU_ITEM_ID)).stream()
.filter(e -> e.getText().equals("Drawing"))
.findFirst()
.orElseThrow(() -> new RuntimeException("No se encontró 'Drawing' en el menú"))
.click();
// Manejar permiso si aparece ANTES de esperar drawingTV
try {
WebDriverWait shortWait = new WebDriverWait(driver, Duration.ofSeconds(3));
shortWait.until(ExpectedConditions.visibilityOfElementLocated(
AppiumBy.id("com.android.permissioncontroller:id/permission_allow_button")
)).click();
} catch (Exception e) {
// Permiso ya otorgado, no aparece
}
wait.until(ExpectedConditions.visibilityOfElementLocated(
AppiumBy.id("com.saucelabs.mydemoapp.android:id/drawingTV")
));
return new DrawingPage(driver, wait);
}
El menú tiene todos los items con el mismo resource-id (itemTV). Usé streams de Java para filtrar por texto. Podría haber usado XPath, pero .stream().filter() es más limpio.
Error 1: el permiso que bloquea todo
Los 4 tests fallaron en la primera corrida. Todos con el mismo error: TimeoutException esperando drawingTV o clearBtn.
El problema: al entrar a Drawing, la app pide permiso de acceso a fotos. El diálogo del sistema bloquea toda la pantalla. Appium no puede encontrar ningún elemento de Drawing porque el diálogo está encima.

Había asumido que el permiso aparecería al tocar Save. No. Aparece al entrar a la pantalla. Mi irADrawing() original esperaba drawingTV inmediatamente después del click, pero el diálogo estaba encima.
La solución: manejar el permiso dentro de irADrawing(), antes de esperar drawingTV. Usé un WebDriverWait corto (3 segundos) con try/catch — si el permiso ya fue otorgado en una ejecución anterior, el catch lo ignora y continúa.
Después de este fix, el autoGrantPermissions del setUp importa:
options.setCapability("appium:autoGrantPermissions", false);
Con false, el diálogo aparece y el test lo maneja. Con true (o sin ponerlo), Android otorga permisos automáticamente y el try/catch cae en el catch silenciosamente. Ambos funcionan, pero con false el test es más completo.
Error 2: el test de swipe que no tenía sentido
Armé un cuarto test: swipeEnCatalogoConW3CActions. Hacía un swipe con W3C Actions en el catálogo y verificaba que apareciera un producto.
Falló porque buscaba un producto con texto incorrecto. Pero al analizarlo, el problema era más profundo: ya hacía scroll en el catálogo con UiScrollable en otros tests. El swipe con W3C Actions sobre el catálogo era hacer lo mismo con otra técnica, sin agregar valor.
Lo eliminé. Los 3 tests de Drawing demuestran W3C Actions con un caso de uso real — dibujar en canvas, donde no hay otra forma de interactuar. El catálogo ya está cubierto con UiScrollable.
DrawingPage
La page nueva completa:
package pages;
import io.appium.java_client.AppiumBy;
import io.appium.java_client.android.AndroidDriver;
import org.openqa.selenium.interactions.PointerInput;
import org.openqa.selenium.interactions.Sequence;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import java.time.Duration;
import java.util.Arrays;
public class DrawingPage extends BasePage {
// Pantalla
private static final String TITLE = "com.saucelabs.mydemoapp.android:id/drawingTV";
private static final String CANVAS = "com.saucelabs.mydemoapp.android:id/signature_pad";
private static final String CLEAR_BTN = "com.saucelabs.mydemoapp.android:id/clearBtn";
private static final String SAVE_BTN = "com.saucelabs.mydemoapp.android:id/saveBtn";
// Diálogo de confirmación
private static final String ALERT_TITLE = "com.saucelabs.mydemoapp.android:id/alertTitle";
private static final String ALERT_MESSAGE = "android:id/message";
private static final String ALERT_OK = "android:id/button1";
public DrawingPage(AndroidDriver driver, WebDriverWait wait) {
super(driver, wait);
}
public String obtenerTitulo() {
return driver.findElement(AppiumBy.id(TITLE)).getText();
}
public void dibujar(int startX, int startY, int endX, int endY) {
PointerInput finger = new PointerInput(PointerInput.Kind.TOUCH, "finger");
Sequence trazo = new Sequence(finger, 1);
trazo.addAction(finger.createPointerMove(Duration.ZERO,
PointerInput.Origin.viewport(), startX, startY));
trazo.addAction(finger.createPointerDown(PointerInput.MouseButton.LEFT.asArg()));
trazo.addAction(finger.createPointerMove(Duration.ofMillis(1000),
PointerInput.Origin.viewport(), endX, endY));
trazo.addAction(finger.createPointerUp(PointerInput.MouseButton.LEFT.asArg()));
driver.perform(Arrays.asList(trazo));
}
public void limpiarCanvas() {
driver.findElement(AppiumBy.id(CLEAR_BTN)).click();
}
public void tapSave() {
driver.findElement(AppiumBy.id(SAVE_BTN)).click();
}
public String obtenerMensajeConfirmacion() {
return wait.until(ExpectedConditions.visibilityOfElementLocated(
AppiumBy.id(ALERT_MESSAGE)
)).getText();
}
public void cerrarAlerta() {
driver.findElement(AppiumBy.id(ALERT_OK)).click();
}
public String guardarYObtenerMensaje() {
tapSave();
String mensaje = obtenerMensajeConfirmacion();
cerrarAlerta();
return mensaje;
}
}
El método dibujar() recibe coordenadas absolutas. Es intencional: las coordenadas dependen del dispositivo y de los bounds del canvas. En mi Motorola G51, el canvas va de (38, 471) a (1042, 2142). Coordenadas (200, 600) → (800, 1200) caen dentro del canvas con margen.
Los 3 tests
@Test
public void navegarADrawing() {
DrawingPage drawingPage = productsPage.irADrawing();
Assert.assertEquals(drawingPage.obtenerTitulo(), "Drawing");
}
Verifica que el menú lateral funciona y que la navegación llega a Drawing. Incluye el manejo del permiso de fotos.
@Test
public void dibujarYGuardar() {
DrawingPage drawingPage = productsPage.irADrawing();
drawingPage.dibujar(200, 600, 800, 1200);
String mensaje = drawingPage.guardarYObtenerMensaje();
Assert.assertEquals(mensaje, "Drawing saved successfully to gallery");
}
El test E2E de gestos: navegar → dibujar con W3C Actions → Save → verificar confirmación. Si "Drawing saved successfully to gallery" aparece, el gesto funcionó y el flujo completo pasó.
@Test
public void dibujarLimpiarYGuardar() {
DrawingPage drawingPage = productsPage.irADrawing();
drawingPage.dibujar(200, 600, 800, 1200);
drawingPage.limpiarCanvas();
String mensaje = drawingPage.guardarYObtenerMensaje();
Assert.assertEquals(mensaje, "Drawing saved successfully to gallery");
}
Dibujar, limpiar con Clear, guardar. Verifica que Clear funciona y que el flujo Save sigue funcionando después de limpiar.
19 tests verdes

Tests passed: 19 of 19 tests – 11 min 45 sec
CartTest 2 min 16 sec
agregarDosProductosDistintos
eliminarProductoDelCarrito
verificarCantidadMultipleEnCarrito
verificarProductoEnCarrito
LoginTest 2 min 9 sec
loginConCamposVacios
loginConClearYReingreso
loginExitoso
scrollHastaProducto
GestosTest 1 min 34 sec
dibujarLimpiarYGuardar 6 sec
dibujarYGuardar 5 sec
navegarADrawing 3 sec
CheckoutTest 3 min 48 sec
checkoutCompleto
continuarComprandoVuelveAProducts
verificarCheckoutComplete
verificarDatosEnReview
ProductDetailTest 1 min 57 sec
agregarAlCarrito
modificarCantidad
seleccionarColor
verificarDetalleProducto
Los tests de gestos son rápidos (~3-6 sec) comparados con checkout (~30 sec). No hay flujo de compra, no hay formularios. Solo navegación, gesto y verificación.
Errores del post
| Error | Causa | Solución |
|---|---|---|
| TimeoutException en todos los tests al entrar a Drawing | Diálogo de permisos bloquea la pantalla antes de que aparezca drawingTV | Manejar permiso con try/catch y shortWait dentro de irADrawing() |
| swipeEnCatalogoConW3CActions fallaba y no aportaba valor | UiScrollable ya cubre scroll en catálogo; W3C Actions para catálogo es redundante | Eliminar el test; W3C Actions demostradas en Drawing donde realmente se necesitan |
Estructura actual
appium-java-framework/
├── src/
│ ├── main/java/
│ │ └── pages/
│ │ ├── BasePage.java (+ swipe con W3C Actions)
│ │ ├── CartPage.java
│ │ ├── CheckoutCompletePage.java
│ │ ├── CheckoutPaymentPage.java
│ │ ├── CheckoutShippingPage.java
│ │ ├── DrawingPage.java ← nuevo
│ │ ├── LoginPage.java
│ │ ├── ProductDetailPage.java
│ │ ├── ProductsPage.java (+ abrirMenu, irADrawing)
│ │ └── ReviewOrderPage.java
│ └── test/java/
│ └── tests/
│ ├── CartTest.java
│ ├── CheckoutTest.java
│ ├── GestosTest.java ← nuevo
│ ├── LoginTest.java
│ ├── PrimerTest.java
│ └── ProductDetailTest.java
├── apk/
│ └── mda-2.2.0-25.apk
└── pom.xml
10 pages, 6 test classes, 19 tests. Primera interacción fuera del flujo de compra: menú lateral, canvas, permisos del sistema, diálogos de confirmación.
Estado actual
- 10 pages: BasePage, ProductsPage, LoginPage, ProductDetailPage, CartPage, CheckoutShippingPage, CheckoutPaymentPage, ReviewOrderPage, CheckoutCompletePage, DrawingPage
- 19 tests: 4 login, 4 product detail, 4 cart, 4 checkout, 3 gestos
- Nuevo: W3C Actions, menú lateral, permisos del sistema, Appium Inspector Gestures/Recorder
- Próximo post: permisos y ciclo de vida de la app