Permisos del sistema y ciclo de vida en Appium: QR Scanner, Geo Location y BaseTest
Diálogos de permisos de Android con manejarPermiso() varargs, BaseTest para eliminar duplicación, runAppInBackground y terminateApp. 25 tests.
CONTEXTO
En el post anterior W3C Actions en Appium armé DrawingPage con W3C Actions. Al entrar a Drawing, la app pedía permiso de acceso a fotos y lo manejé con un try/catch puntual dentro de irADrawing(). Funcionaba, pero era código específico para una sola pantalla.
El menú lateral de My Demo App tiene más pantallas que piden permisos: QR Code Scanner necesita la cámara, Geo Location necesita la ubicación. Cada una con un diálogo del sistema diferente. Si repito el try/catch en cada irA...(), termino con el mismo código pegado en 3 lugares.
Aparte, tenía otra deuda técnica: el setUp/tearDown de los tests. Las 6 clases de test tenían el mismo @BeforeMethod y @AfterMethod copiado — crear driver, configurar capabilities, esperar que cargue la app, cerrar driver. Lo mismo repetido 6 veces.
Este post resuelve las dos cosas: centralizar permisos y eliminar duplicación en tests.
BaseTest: eliminar la duplicación
Antes, cada clase de test tenía esto:
public class GestosTest {
private AndroidDriver driver;
private WebDriverWait wait;
private ProductsPage productsPage;
@BeforeMethod
public void setUp() throws Exception {
UiAutomator2Options options = new UiAutomator2Options();
options.setUdid("ZY32FJFXNF");
options.setApp(new File("apk/mda-2.2.0-25.apk").getAbsolutePath());
options.setCapability("appium:autoGrantPermissions", false);
options.setAutomationName("UiAutomator2");
options.setNewCommandTimeout(Duration.ofSeconds(120));
options.setCapability("appium:appWaitActivity", "*");
driver = new AndroidDriver(new URL("http://127.0.0.1:4723"), options);
wait = new WebDriverWait(driver, Duration.ofSeconds(10));
productsPage = new ProductsPage(driver, wait);
wait.until(ExpectedConditions.visibilityOfElementLocated(
AppiumBy.accessibilityId("Displays all products of catalog")
));
}
@AfterMethod
public void tearDown() {
if (driver != null) {
driver.quit();
}
}
// tests...
}
Esto estaba copiado en CartTest, LoginTest, GestosTest, CheckoutTest, ProductDetailTest. Lo mismo, 6 veces. Si cambiaba el UDID del dispositivo o el timeout, tenía que editar 6 archivos.
Creé BaseTest:
package tests;
import io.appium.java_client.AppiumBy;
import io.appium.java_client.android.AndroidDriver;
import io.appium.java_client.android.options.UiAutomator2Options;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import pages.ProductsPage;
import java.io.File;
import java.net.URL;
import java.time.Duration;
public class BaseTest {
protected AndroidDriver driver;
protected WebDriverWait wait;
protected ProductsPage productsPage;
@BeforeMethod
public void setUp() throws Exception {
UiAutomator2Options options = new UiAutomator2Options();
options.setUdid("ZY32FJFXNF");
options.setApp(new File("apk/mda-2.2.0-25.apk").getAbsolutePath());
options.setCapability("appium:autoGrantPermissions", false);
options.setAutomationName("UiAutomator2");
options.setNewCommandTimeout(Duration.ofSeconds(120));
options.setCapability("appium:appWaitActivity", "*");
driver = new AndroidDriver(new URL("http://127.0.0.1:4723"), options);
wait = new WebDriverWait(driver, Duration.ofSeconds(10));
productsPage = new ProductsPage(driver, wait);
wait.until(ExpectedConditions.visibilityOfElementLocated(
AppiumBy.accessibilityId("Displays all products of catalog")
));
}
@AfterMethod
public void tearDown() {
if (driver != null) {
driver.quit();
}
}
}
Los campos son protected para que las subclases accedan a driver, wait y productsPage directamente.
Ahora GestosTest queda así:
public class GestosTest extends BaseTest {
@Test
public void navegarADrawing() {
DrawingPage drawingPage = productsPage.irADrawing();
Assert.assertEquals(drawingPage.obtenerTitulo(), "Drawing");
}
@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");
}
@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");
}
}
Sin setUp, sin tearDown, sin campos duplicados. Lo mismo hice con las otras 5 clases. Es el mismo patrón que BasePage aplica a las pages — herencia para centralizar lo que se repite.
autoGrantPermissions: true vs false
En BaseTest configuré:
options.setCapability("appium:autoGrantPermissions", false);
Con true (o sin configurarlo), Android otorga todos los permisos automáticamente al instalar la APK. Los diálogos de permisos nunca aparecen. Los tests funcionan, pero no prueban nada relacionado con permisos.
Con false, la app se instala sin permisos. Cada vez que una pantalla necesita un recurso (cámara, ubicación, fotos), el sistema muestra el diálogo. Los tests tienen que manejarlo.
Para un post de permisos, false es obligatorio. Pero hay algo más: con false, los tests de Drawing y QR Scanner y Geo Location necesitan manejar el diálogo cada vez que corren, porque @BeforeMethod reinstala la app limpia en cada test.
Explorando con Inspector: no todos los diálogos son iguales
Antes de codear, exploré QR Code Scanner y Geo Location con Appium Inspector. Acá descubrí algo que no esperaba.
QR Code Scanner — permiso de cámara

El diálogo de cámara tiene 3 botones:
- "Mientras la app está en uso" →
permission_allow_foreground_only_button - "Solo esta vez" →
permission_allow_one_time_button - "No permitir" →
permission_deny_button
Después de aceptar, la pantalla QR Scanner carga con el preview de la cámara y un título con id qrCodeTV.
Geo Location — permiso de ubicación

El diálogo de ubicación es más complejo. Tiene los mismos 3 botones, pero además tiene RadioButtons:
- "Precisa" →
permission_location_accuracy_radio_fine - "Aproximada" →
permission_location_accuracy_radio_coarse
Después de aceptar, Geo Location muestra coordenadas reales (en mi caso, latitud -31.313295 y longitud -64.2676153 — Córdoba), botones Start/Stop Observing, y un link.
Drawing — permiso de fotos (el que ya conocía)

El diálogo de fotos tiene solo 2 botones:
- "Permitir" →
permission_allow_button - "No permitir" →
permission_deny_button
Acá está el hallazgo: no todos los diálogos de permisos de Android tienen los mismos botones. El de fotos/media tiene 2 opciones con permission_allow_button. El de cámara y ubicación tiene 3 opciones con permission_allow_foreground_only_button. Son ids diferentes del sistema.
Si hardcodeás un solo id para manejar permisos, va a fallar en alguna pantalla.
manejarPermiso() con varargs
En el post de Drawing, el manejo del permiso era un try/catch con un id fijo dentro de irADrawing(). Ahora necesitaba manejar permisos en 3 pantallas, con ids diferentes. La solución: un método genérico en BasePage que acepta múltiples ids.
/**
* Maneja diálogo de permisos del sistema Android.
* Si el diálogo aparece, toca el botón indicado.
* Si no aparece (permiso ya otorgado), continúa sin error.
*/
public void manejarPermiso(String... botonesPermiso) {
WebDriverWait shortWait = new WebDriverWait(driver, Duration.ofSeconds(3));
for (String boton : botonesPermiso) {
try {
shortWait.until(ExpectedConditions.visibilityOfElementLocated(
AppiumBy.id(boton)
)).click();
return;
} catch (Exception e) {
// Este botón no apareció, probar el siguiente
}
}
}
String... botonesPermiso (varargs) permite pasar uno o más ids. El método prueba cada uno: si encuentra el primero, hace click y sale. Si no lo encuentra, prueba el siguiente. Si ninguno aparece, continúa sin error (el permiso ya fue otorgado).
En ProductsPage, los métodos quedan así:
private static final String PERMISO_ALLOW =
"com.android.permissioncontroller:id/permission_allow_foreground_only_button";
public DrawingPage irADrawing() {
abrirMenu();
// ... seleccionar Drawing del menú ...
// Drawing usa permission_allow_button (2 opciones)
// QR/Geo usan permission_allow_foreground_only_button (3 opciones)
manejarPermiso(PERMISO_ALLOW,
"com.android.permissioncontroller:id/permission_allow_button");
wait.until(ExpectedConditions.visibilityOfElementLocated(
AppiumBy.id("com.saucelabs.mydemoapp.android:id/drawingTV")
));
return new DrawingPage(driver, wait);
}
public QRScannerPage irAQRScanner() {
abrirMenu();
// ... seleccionar QR Code Scanner del menú ...
manejarPermiso(PERMISO_ALLOW);
wait.until(ExpectedConditions.visibilityOfElementLocated(
AppiumBy.id("com.saucelabs.mydemoapp.android:id/qrCodeTV")
));
return new QRScannerPage(driver, wait);
}
public GeoLocationPage irAGeoLocation() {
abrirMenu();
// ... seleccionar Geo Location del menú ...
manejarPermiso(PERMISO_ALLOW);
wait.until(ExpectedConditions.visibilityOfElementLocated(
AppiumBy.id("com.saucelabs.mydemoapp.android:id/locationTV")
));
return new GeoLocationPage(driver, wait);
}
irADrawing() pasa 2 ids porque el diálogo puede tener cualquiera de los dos formatos. irAQRScanner() e irAGeoLocation() pasan solo PERMISO_ALLOW porque ambos usan el diálogo de 3 opciones.
Hallazgo: denegar permiso de cámara
Armé un test para denegar el permiso de QR Scanner y verificar qué pasaba. Esperaba que la app mostrara un mensaje de error o volviera a Products.
Lo que pasó: al denegar, la app vuelve a pedir el permiso inmediatamente. No hay pantalla alternativa, no hay mensaje de error. Sin permiso no entrás a QR Scanner.
El test quedaba en un loop: denegaba → aparecía de nuevo → timeout. Lo eliminé. No hay assertion útil que hacer — el comportamiento de la app es "sin permiso no entrás". Lo documento como hallazgo en el post, no como test.
QRScannerPage
package pages;
import io.appium.java_client.AppiumBy;
import io.appium.java_client.android.AndroidDriver;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
public class QRScannerPage extends BasePage {
private static final String TITLE = "com.saucelabs.mydemoapp.android:id/qrCodeTV";
public QRScannerPage(AndroidDriver driver, WebDriverWait wait) {
super(driver, wait);
}
public String obtenerTitulo() {
return wait.until(ExpectedConditions.visibilityOfElementLocated(
AppiumBy.id(TITLE)
)).getText();
}
}
La page más simple del framework. Solo tiene el título. El preview de la cámara es un View genérico sin id útil — no se pueden hacer assertions sobre la imagen de la cámara.
El valor de esta page no está en su complejidad. Está en lo que pasa para llegar a ella: abrir menú → seleccionar item → manejar permiso de cámara → verificar que cargó.
GeoLocationPage
package pages;
import io.appium.java_client.AppiumBy;
import io.appium.java_client.android.AndroidDriver;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
public class GeoLocationPage extends BasePage {
private static final String TITLE = "com.saucelabs.mydemoapp.android:id/locationTV";
private static final String START_BTN = "com.saucelabs.mydemoapp.android:id/startBtn";
private static final String STOP_BTN = "com.saucelabs.mydemoapp.android:id/stopBtn";
private static final String LATITUDE_TV = "com.saucelabs.mydemoapp.android:id/latitudeTV";
private static final String LONGITUDE_TV = "com.saucelabs.mydemoapp.android:id/longitudeTV";
public GeoLocationPage(AndroidDriver driver, WebDriverWait wait) {
super(driver, wait);
}
public String obtenerTitulo() {
return wait.until(ExpectedConditions.visibilityOfElementLocated(
AppiumBy.id(TITLE)
)).getText();
}
public String obtenerLatitud() {
return wait.until(ExpectedConditions.visibilityOfElementLocated(
AppiumBy.id(LATITUDE_TV)
)).getText();
}
public String obtenerLongitud() {
return wait.until(ExpectedConditions.visibilityOfElementLocated(
AppiumBy.id(LONGITUDE_TV)
)).getText();
}
public void tapStartObserving() {
wait.until(ExpectedConditions.elementToBeClickable(
AppiumBy.id(START_BTN)
)).click();
}
public void tapStopObserving() {
wait.until(ExpectedConditions.elementToBeClickable(
AppiumBy.id(STOP_BTN)
)).click();
}
}

La pantalla más rica de este post. Tiene coordenadas reales del dispositivo, botones para controlar el tracking, y un link. Las coordenadas que aparecen en mi Motorola (-31.313295, -64.2676153) son Córdoba — el GPS del dispositivo funciona.
PermisosTest
package tests;
import org.testng.Assert;
import org.testng.annotations.Test;
import pages.GeoLocationPage;
import pages.QRScannerPage;
public class PermisosTest extends BaseTest {
@Test
public void navegarAQRScanner() {
QRScannerPage qrPage = productsPage.irAQRScanner();
Assert.assertEquals(qrPage.obtenerTitulo(), "QR Code Scanner");
}
@Test
public void navegarAGeoLocation() {
GeoLocationPage geoPage = productsPage.irAGeoLocation();
Assert.assertEquals(geoPage.obtenerTitulo(), "Geo Location");
}
@Test
public void verificarCoordenadasGeoLocation() {
GeoLocationPage geoPage = productsPage.irAGeoLocation();
String latitud = geoPage.obtenerLatitud();
String longitud = geoPage.obtenerLongitud();
Assert.assertFalse(latitud.isEmpty(), "Latitud no debería estar vacía");
Assert.assertFalse(longitud.isEmpty(), "Longitud no debería estar vacía");
}
@Test
public void startStopObserving() {
GeoLocationPage geoPage = productsPage.irAGeoLocation();
geoPage.tapStartObserving();
String latitud = geoPage.obtenerLatitud();
Assert.assertFalse(latitud.isEmpty(),
"Latitud no debería estar vacía después de Start");
geoPage.tapStopObserving();
}
}
Cuatro tests. Cada uno maneja permisos internamente vía irAQRScanner() / irAGeoLocation(). Los tests no saben cómo se manejan los permisos — eso queda en ProductsPage. POM en acción: los tests llaman métodos de page, los locators y la lógica de permisos quedan encapsulados.
LifecycleTest
package tests;
import org.testng.Assert;
import org.testng.annotations.Test;
import java.time.Duration;
public class LifecycleTest extends BaseTest {
@Test
public void appEnBackground() {
Assert.assertTrue(productsPage.obtenerTitulo().contains("Products"));
driver.runAppInBackground(Duration.ofSeconds(5));
Assert.assertTrue(productsPage.obtenerTitulo().contains("Products"));
}
@Test
public void terminateYReactivar() {
String appPackage = "com.saucelabs.mydemoapp.android";
driver.terminateApp(appPackage);
driver.activateApp(appPackage);
Assert.assertTrue(productsPage.obtenerTitulo().contains("Products"));
}
}
Dos tests de ciclo de vida:
appEnBackground: runAppInBackground(Duration.ofSeconds(5)) manda la app a segundo plano por 5 segundos. Android la mantiene en memoria. Al volver, tiene que seguir en Products. Simula cuando el usuario cambia de app y vuelve.
terminateYReactivar: terminateApp() cierra la app completamente. activateApp() la reabre. Es un kill real — la app arranca desde cero. Simula cuando Android mata la app por falta de memoria.
Ambos tests acceden a driver directamente — por eso los campos de BaseTest son protected. Si fueran private, LifecycleTest no compilaría.
25 tests verdes

Tests passed: 25 of 25 tests – 15 min 23 sec
CartTest 2 min 19 sec
agregarDosProductosDistintos
eliminarProductoDelCarrito
verificarCantidadMultipleEnCarrito
verificarProductoEnCarrito
LoginTest 2 min 7 sec
loginConCamposVacios
loginConClearYReingreso
loginExitoso
scrollHastaProducto
GestosTest 1 min 45 sec
dibujarLimpiarYGuardar
dibujarYGuardar
navegarADrawing
CheckoutTest 3 min 55 sec
checkoutCompleto
continuarComprandoVuelveAProducts
verificarCheckoutComplete
verificarDatosEnReview
PermisosTest 2 min 7 sec
navegarAGeoLocation 3 sec
navegarAQRScanner 3 sec
startStopObserving 4 sec
verificarCoordenadasGeoLoc 3 sec
LifecycleTest 1 min 10 sec
appEnBackground 8 sec
terminateYReactivar 6 sec
ProductDetailTest 2 min 1 sec
agregarAlCarrito
modificarCantidad
seleccionarColor
verificarDetalleProducto
Los tests de permisos son rápidos (~3-4 sec cada uno). Los de lifecycle son algo más lentos (~6-8 sec) por el tiempo de background y reinicio de la app.
Errores del post
| Error | Causa | Solución |
|---|---|---|
| 3 tests de GestosTest fallaron después del refactor | manejarPermiso(PERMISO_ALLOW) usaba permission_allow_foreground_only_button pero Drawing tiene permission_allow_button |
Pasar ambos ids con varargs: manejarPermiso(PERMISO_ALLOW, "...permission_allow_button") |
| Test de denegar permiso QR Scanner en timeout | La app re-pide el permiso inmediatamente al denegar — no hay pantalla alternativa | Eliminar el test; documentar el comportamiento como hallazgo |
| SessionNotCreatedException en corrida completa | Appium server no estaba corriendo o dispositivo desconectado | Verificar adb devices y reiniciar Appium server |
Estructura actual
appium-java-framework/
├── src/
│ ├── main/java/
│ │ └── pages/
│ │ ├── BasePage.java (+ manejarPermiso con varargs)
│ │ ├── CartPage.java
│ │ ├── CheckoutCompletePage.java
│ │ ├── CheckoutPaymentPage.java
│ │ ├── CheckoutShippingPage.java
│ │ ├── DrawingPage.java
│ │ ├── GeoLocationPage.java ← nuevo
│ │ ├── LoginPage.java
│ │ ├── ProductDetailPage.java
│ │ ├── ProductsPage.java (+ irAQRScanner, irAGeoLocation)
│ │ ├── QRScannerPage.java ← nuevo
│ │ └── ReviewOrderPage.java
│ └── test/java/
│ └── tests/
│ ├── BaseTest.java ← nuevo
│ ├── CartTest.java (extends BaseTest)
│ ├── CheckoutTest.java (extends BaseTest)
│ ├── GestosTest.java (extends BaseTest)
│ ├── LifecycleTest.java ← nuevo
│ ├── LoginTest.java (extends BaseTest)
│ ├── PermisosTest.java ← nuevo
│ ├── PrimerTest.java
│ └── ProductDetailTest.java (extends BaseTest)
├── apk/
│ └── mda-2.2.0-25.apk
└── pom.xml
12 pages, 9 test classes, 25 tests.
Estado actual
- 12 pages: BasePage, ProductsPage, LoginPage, ProductDetailPage, CartPage, CheckoutShippingPage, CheckoutPaymentPage, ReviewOrderPage, CheckoutCompletePage, DrawingPage, QRScannerPage, GeoLocationPage
- 25 tests: 4 login, 4 product detail, 4 cart, 4 checkout, 3 gestos, 4 permisos, 2 lifecycle
- Nuevo: BaseTest (herencia para tests), manejarPermiso() con varargs, permisos del sistema Android, runAppInBackground, terminateApp/activateApp
- Hallazgo: los diálogos de permisos de Android tienen ids diferentes según el recurso (fotos vs cámara vs ubicación)
- Próximo post: WebViews en Appium — cambio de contexto nativo a web