WebViews en Appium: cambio de contexto nativo a web
ChromeDriver manual para Chrome 148, getContextHandles(), switch a WEBVIEW, login en saucedemo.com con locators web. De 25 a 30 tests verdes.
CONTEXTO
Hasta ahora todos los tests interactúan con elementos nativos de Android: TextViews, Buttons, EditTexts con resource-ids del package de la app. Funciona porque My Demo App es mayormente nativa.
Pero en el mundo real, muchas apps embeben páginas web dentro de la app. Un login con OAuth que abre una web, una sección de ayuda que carga un HTML, un checkout que es una web embebida, términos y condiciones. Todo eso son WebViews. La app sigue siendo la misma, pero por dentro hay un navegador renderizando HTML.
El problema para automation: Appium por defecto está en contexto NATIVE_APP. Solo ve elementos Android. Si necesito interactuar con elementos web dentro del WebView — un <input>, un <button> de HTML — tengo que cambiar de contexto a WEBVIEW. Ahí Appium deja de usar UiAutomator2 y pasa a usar ChromeDriver, como si fuera Selenium contra un browser.
My Demo App tiene una pantalla "WebView" en el menú lateral. Permite ingresar una URL y cargarla dentro de la app. La usé para cargar saucedemo.com (de Sauce Labs, misma empresa que My Demo App) y testear el switch de contexto.
Explorando con Inspector: la pantalla WebView
La pantalla WebView tiene tres elementos nativos:

| Elemento | resource-id | Tipo |
|---|---|---|
| Título "Webview" | webViewTV |
TextView |
| Campo URL | urlET |
EditText |
| Botón "Go To Site" | goBtn |
Button |
Ingresé https://www.saucedemo.com y toqué "Go To Site". La página de Swag Labs cargó dentro de la app.

Hallazgo: Inspector muestra los elementos web como widgets nativos
Antes de pensar en cambiar contextos, refresqué Inspector con saucedemo cargada. Lo que vi no lo esperaba.

El campo Username de saucedemo aparece como android.widget.EditText con resource-id="user-name". El password igual. El botón Login como android.widget.Button con resource-id="login-button". Todo con clases Android, no HTML.
UiAutomator2 puede "ver adentro" del WebView y mapear elementos HTML a widgets nativos. Eso significa que podría interactuar con ellos sin switchear contexto — usar AppiumBy.id("user-name") y funcionaría.
Entonces, ¿para qué switchear contexto?
Por qué switchear a WEBVIEW si el nativo funciona
Hay casos donde el mapeo nativo no alcanza:
- Locators CSS complejos que no tienen equivalente nativo
- Frames o iframes dentro del WebView
- Elementos dinámicos generados por JavaScript que UiAutomator2 no renderiza bien
- Assertions sobre el DOM real (título de la página, URL actual, contenido HTML)
Para este post, lo valioso es aprender el mecanismo. Después aplico el criterio de cuándo usarlo.
Obteniendo los contextos disponibles
Desde Inspector, en Commands → Execute Methods → busqué mobile: getContexts y ejecuté con waitForWebviewMs: 5000.

El resultado muestra:
- Contexto WebView:
WEBVIEW_com.saucelabs.mydemoapp.android - Chrome del dispositivo:
Chrome/148.0.7778.120 - Página cargada:
https://www.saucedemo.com/ - WebView attached y visible
En código, driver.getContextHandles() devuelve lo mismo pero simplificado: [NATIVE_APP, WEBVIEW_com.saucelabs.mydemoapp.android].
Error 1: ChromeDriver no encontrado
Al intentar switchAppiumContext a WEBVIEW_com.saucelabs.mydemoapp.android desde Inspector:
WebDriverError: No Chromedriver found that can automate Chrome '148.0.7778'.
You could also try to enable automated chromedriver download as a possible workaround.

Cuando switcheás a WEBVIEW, Appium necesita ChromeDriver — el mismo que Selenium usa para automatizar Chrome en web. Y la versión tiene que coincidir con el Chrome del dispositivo.
Primer intento: agregué la capability appium:chromedriverAutodownload: true. No funcionó. Chrome 148 es reciente y Appium no tiene el mapping todavía.
La solución: descargar ChromeDriver manualmente.
Mi Motorola tiene Chrome 148.0.7778.178. Descargué el ChromeDriver correspondiente:
https://storage.googleapis.com/chrome-for-testing-public/148.0.7778.178/win64/chromedriver-win64.zip
Lo extraje en chromedriver-win64/ dentro del repo. Y agregué la capability:
options.setCapability("appium:chromedriverExecutable",
new File("../chromedriver-win64/chromedriver.exe").getAbsolutePath());

El ../ es porque el código corre desde el subproyecto appium-java-framework/appium-java-framework/ pero el ChromeDriver está en la raíz del repo appium-java-framework/chromedriver-win64/. Esto también me dio un error (el segundo de este post) — Appium buscaba en la ruta equivocada hasta que agregué el ../.
Switch de contexto: el árbol cambia completamente
Con el ChromeDriver configurado, el switch funcionó. Lo que vi en Inspector fue lo más interesante del post.
Antes (NATIVE_APP):

<android.webkit.WebView>
<android.view.View resource-id="root">
<android.widget.EditText resource-id="user-name">
<android.widget.EditText resource-id="password">
<android.widget.Button resource-id="login-button">
Después (WEBVIEW):

<body>
<div id="root">
<form>
<input id="user-name" name="user-name">
<input id="password" name="password">
<input id="login-button" name="login-button" value="Login">
<div id="login_credentials">
Mismo contenido, dos representaciones completamente diferentes. El mismo campo Username es android.widget.EditText en un contexto y <input id="user-name"> en el otro.
WebViewPage
package pages;
import io.appium.java_client.AppiumBy;
import io.appium.java_client.android.AndroidDriver;
import org.openqa.selenium.By;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import java.util.Set;
public class WebViewPage extends BasePage {
// Elementos nativos (pantalla WebView de My Demo App)
private static final String TITLE = "com.saucelabs.mydemoapp.android:id/webViewTV";
private static final String URL_INPUT = "com.saucelabs.mydemoapp.android:id/urlET";
private static final String GO_BTN = "com.saucelabs.mydemoapp.android:id/goBtn";
// Contexto WebView
private static final String WEBVIEW_CONTEXT = "WEBVIEW_com.saucelabs.mydemoapp.android";
private static final String NATIVE_CONTEXT = "NATIVE_APP";
public WebViewPage(AndroidDriver driver, WebDriverWait wait) {
super(driver, wait);
}
public String obtenerTitulo() {
return wait.until(ExpectedConditions.visibilityOfElementLocated(
AppiumBy.id(TITLE)
)).getText();
}
public void cargarUrl(String url) {
var campo = wait.until(ExpectedConditions.visibilityOfElementLocated(
AppiumBy.id(URL_INPUT)
));
campo.clear();
campo.sendKeys(url);
wait.until(ExpectedConditions.elementToBeClickable(
AppiumBy.id(GO_BTN)
)).click();
}
public Set<String> obtenerContextos() {
return driver.getContextHandles();
}
public void cambiarAWebView() {
wait.until(d -> driver.getContextHandles().size() > 1);
driver.context(WEBVIEW_CONTEXT);
}
public void cambiarANativo() {
driver.context(NATIVE_CONTEXT);
}
public String obtenerContextoActual() {
return driver.getContext();
}
// --- Métodos en contexto WEBVIEW (locators web) ---
public String obtenerTituloWeb() {
return driver.getTitle();
}
public void loginEnWeb(String username, String password) {
wait.until(ExpectedConditions.visibilityOfElementLocated(
By.id("user-name")
)).sendKeys(username);
driver.findElement(By.id("password")).sendKeys(password);
driver.findElement(By.id("login-button")).click();
}
public String obtenerErrorLoginWeb() {
return wait.until(ExpectedConditions.visibilityOfElementLocated(
By.cssSelector("[data-test='error']")
)).getText();
}
public boolean estaEnInventario() {
try {
wait.until(ExpectedConditions.visibilityOfElementLocated(
By.className("inventory_list")
));
return true;
} catch (Exception e) {
return false;
}
}
}
Algo que no tuve en las otras pages: import org.openqa.selenium.By. En las pages nativas uso AppiumBy para todo. Acá necesito By (de Selenium) para los locators web que se usan en contexto WEBVIEW. By.id("user-name"), By.cssSelector(...), By.className(...) — Selenium puro.
Otro detalle: cambiarAWebView() espera a que haya más de un contexto antes de switchear. Después de tocar "Go To Site", el WebView necesita tiempo para cargar la página. Si switcheo antes de que esté listo, falla.
irAWebView() en ProductsPage
public WebViewPage irAWebView() {
abrirMenu();
// WebView está abajo en el menú, necesita scroll
driver.findElement(AppiumBy.androidUIAutomator(
"new UiScrollable(new UiSelector().scrollable(true))" +
".scrollTextIntoView(\"WebView\")"
));
driver.findElements(AppiumBy.id(MENU_ITEM_ID)).stream()
.filter(e -> e.getText().equals("WebView"))
.findFirst()
.orElseThrow(() -> new RuntimeException("No se encontró 'WebView' en el menú"))
.click();
wait.until(ExpectedConditions.visibilityOfElementLocated(
AppiumBy.id("com.saucelabs.mydemoapp.android:id/webViewTV")
));
return new WebViewPage(driver, wait);
}
Error 3: este método falló con "No se encontró 'Webview' en el menú" en la primera corrida. El problema era doble: "WebView" está abajo en el menú (no visible sin scroll), y el texto es "WebView" con V mayúscula, no "Webview". Lo descubrí revisando en Inspector.
Agregué el scrollTextIntoView y corregí la capitalización. Funcionó.
BaseTest actualizado
@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", "*");
options.setCapability("appium:chromedriverExecutable",
new File("../chromedriver-win64/chromedriver.exe").getAbsolutePath());
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")
));
}
La capability chromedriverExecutable se agrega al BaseTest. Todos los tests la tienen disponible aunque no usen WebViews — no afecta a los tests nativos.
WebViewTest
package tests;
import org.testng.Assert;
import org.testng.annotations.Test;
import pages.WebViewPage;
import java.util.Set;
public class WebViewTest extends BaseTest {
private static final String SAUCEDEMO_URL = "https://www.saucedemo.com";
@Test
public void navegarAWebView() {
WebViewPage webViewPage = productsPage.irAWebView();
Assert.assertEquals(webViewPage.obtenerTitulo(), "Webview");
}
@Test
public void cargarUrlYVerificarContextos() {
WebViewPage webViewPage = productsPage.irAWebView();
webViewPage.cargarUrl(SAUCEDEMO_URL);
Set<String> contextos = webViewPage.obtenerContextos();
Assert.assertTrue(contextos.contains("NATIVE_APP"));
Assert.assertTrue(contextos.contains("WEBVIEW_com.saucelabs.mydemoapp.android"));
}
@Test
public void switchAWebViewYVerificarTitulo() {
WebViewPage webViewPage = productsPage.irAWebView();
webViewPage.cargarUrl(SAUCEDEMO_URL);
webViewPage.cambiarAWebView();
String titulo = webViewPage.obtenerTituloWeb();
Assert.assertEquals(titulo, "Swag Labs");
webViewPage.cambiarANativo();
Assert.assertEquals(webViewPage.obtenerContextoActual(), "NATIVE_APP");
}
@Test
public void loginExitosoEnWebView() {
WebViewPage webViewPage = productsPage.irAWebView();
webViewPage.cargarUrl(SAUCEDEMO_URL);
webViewPage.cambiarAWebView();
webViewPage.loginEnWeb("standard_user", "secret_sauce");
Assert.assertTrue(webViewPage.estaEnInventario());
webViewPage.cambiarANativo();
}
@Test
public void loginFallidoEnWebView() {
WebViewPage webViewPage = productsPage.irAWebView();
webViewPage.cargarUrl(SAUCEDEMO_URL);
webViewPage.cambiarAWebView();
webViewPage.loginEnWeb("standard_user", "wrong_password");
String error = webViewPage.obtenerErrorLoginWeb();
Assert.assertTrue(error.contains("Username and password do not match"));
webViewPage.cambiarANativo();
}
}
Cinco tests. Los dos primeros (navegarAWebView, cargarUrlYVerificarContextos) no necesitan switch — se quedan en contexto nativo. Los tres restantes hacen el switch completo: nativo → WEBVIEW → interactuar con web → volver a nativo.
Cada test termina con cambiarANativo(). Es buena práctica: si un test falla a mitad del switch, el tearDown tiene que poder hacer driver.quit() sin problemas.
30 tests verdes

Tests passed: 30 of 30 tests – 18 min 35 sec
CartTest 2 min 14 sec
agregarDosProductosDistintos
eliminarProductoDelCarrito
verificarCantidadMultipleEnCarrito
verificarProductoEnCarrito
LoginTest 2 min 14 sec
loginConCamposVacios
loginConClearYReingreso
loginExitoso
scrollHastaProducto
GestosTest 1 min 44 sec
dibujarLimpiarYGuardar
dibujarYGuardar
navegarADrawing
WebViewTest 3 min 14 sec
cargarUrlYVerificarContextos 7 sec
loginExitosoEnWebView 18 sec
loginFallidoEnWebView 17 sec
navegarAWebView 2 sec
switchAWebViewYVerificarTitulo 15 sec
CheckoutTest 3 min 46 sec
checkoutCompleto
continuarComprandoVuelveAProducts
verificarCheckoutComplete
verificarDatosEnReview
PermisosTest 2 min 6 sec
navegarAGeoLocation
navegarAQRScanner
startStopObserving
verificarCoordenadasGeoLocation
LifecycleTest 1 min 12 sec
appEnBackground
terminateYReactivar
ProductDetailTest (scrolled)
agregarAlCarrito
modificarCantidad
seleccionarColor
verificarDetalleProducto
Los tests de WebView que hacen switch (~15-18 sec) son más lentos que los nativos (~2-7 sec). El overhead viene de iniciar ChromeDriver para el contexto WEBVIEW y de cargar saucedemo.com dentro del WebView.
Errores del post
| Error | Causa | Solución |
|---|---|---|
| ChromeDriver not found para Chrome 148 | Appium no tiene el mapping de ChromeDriver para Chrome 148 | Descargar ChromeDriver 148 manualmente y usar chromedriverExecutable |
chromedriverAutodownload: true no resolvió |
Chrome 148 demasiado reciente para el autodownload | ChromeDriver manual en vez de autodownload |
| "No se encontró 'Webview' en el menú" | WebView está abajo en el menú (necesita scroll) y el texto es "WebView" con V mayúscula | scrollTextIntoView("WebView") + corregir capitalización |
| ChromeDriver path incorrecto desde código | El código corre desde appium-java-framework/appium-java-framework/ pero el ChromeDriver está en la raíz |
Usar ../chromedriver-win64/chromedriver.exe con ../ |
Estructura actual
appium-java-framework/
├── chromedriver-win64/
│ └── chromedriver.exe ← nuevo (ChromeDriver 148)
├── appium-java-framework/
│ ├── src/
│ │ ├── main/java/
│ │ │ └── pages/
│ │ │ ├── BasePage.java
│ │ │ ├── CartPage.java
│ │ │ ├── CheckoutCompletePage.java
│ │ │ ├── CheckoutPaymentPage.java
│ │ │ ├── CheckoutShippingPage.java
│ │ │ ├── DrawingPage.java
│ │ │ ├── GeoLocationPage.java
│ │ │ ├── LoginPage.java
│ │ │ ├── ProductDetailPage.java
│ │ │ ├── ProductsPage.java (+ irAWebView)
│ │ │ ├── QRScannerPage.java
│ │ │ ├── ReviewOrderPage.java
│ │ │ └── WebViewPage.java ← nuevo
│ │ └── test/java/
│ │ └── tests/
│ │ ├── BaseTest.java (+ chromedriverExecutable)
│ │ ├── CartTest.java
│ │ ├── CheckoutTest.java
│ │ ├── GestosTest.java
│ │ ├── LifecycleTest.java
│ │ ├── LoginTest.java
│ │ ├── PermisosTest.java
│ │ ├── PrimerTest.java
│ │ ├── ProductDetailTest.java
│ │ └── WebViewTest.java ← nuevo
│ ├── apk/
│ │ └── mda-2.2.0-25.apk
│ └── pom.xml
13 pages, 10 test classes, 30 tests.
Estado actual
- 13 pages: BasePage, ProductsPage, LoginPage, ProductDetailPage, CartPage, CheckoutShippingPage, CheckoutPaymentPage, ReviewOrderPage, CheckoutCompletePage, DrawingPage, QRScannerPage, GeoLocationPage, WebViewPage
- 30 tests: 4 login, 4 product detail, 4 cart, 4 checkout, 3 gestos, 4 permisos, 2 lifecycle, 5 webview
- Nuevo: WebViewPage con cambio de contexto NATIVE_APP ↔ WEBVIEW, ChromeDriver manual, locators web con
By.id()/By.cssSelector(), login en saucedemo.com desde dentro de la app - Hallazgo: UiAutomator2 puede interactuar con elementos web sin switchear contexto (los mapea a widgets Android), pero el switch da acceso al DOM real y a locators CSS
- Próximo post: DataProvider — parametrización de tests con múltiples sets de datos