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.

Comparación lado a lado en Appium Inspector entre contexto NATIVE_APP con android.widget.EditText y contexto WEBVIEW con input id user-name sobre saucedemo.com
Mismo contenido, dos contextos. Izquierda: NATIVE_APP con widgets Android. Derecha: WEBVIEW con DOM HTML. El corazón de este post.

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:

Pantalla WebView de My Demo App con campo URL, mensaje de validación y botón Go To Site
Pantalla WebView antes de cargar una URL. Tres elementos nativos: título (webViewTV), campo URL (urlET) y botón Go To Site (goBtn).
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.

Login de Swag Labs renderizado dentro del WebView de My Demo App con header nativo visible
saucedemo.com renderizado dentro del WebView. El header nativo de MYDEMOAPP sigue arriba — la web vive 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.

Appium Inspector en contexto NATIVE_APP mostrando el input Username como android.widget.EditText con resource-id user-name
Contexto NATIVE_APP. Inspector muestra el input Username como android.widget.EditText. UiAutomator2 "ve adentro" del WebView y mapea los elementos HTML a clases Android.

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.

Resultado de getContexts en Inspector mostrando WEBVIEW_com.saucelabs.mydemoapp.android con Chrome 148.0.7778.120
getContexts devuelve info detallada: Android-Package, versión de Chrome, WebKit, User-Agent y la URL cargada (saucedemo.com).

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.
Error en Appium Inspector al intentar switch a WEBVIEW indicando que no encuentra ChromeDriver para Chrome 148
Primer error. Appium necesita ChromeDriver para hablar con el WebView, y la versión tiene que coincidir con el Chrome del dispositivo.

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());
Appium Inspector Capability Builder con siete capabilities configuradas incluyendo chromedriverAutodownload true y chromedriverExecutable apuntando al ChromeDriver descargado
Capabilities en Inspector para WebView. Las dos nuevas: chromedriverAutodownload (que no resolvió sola) y chromedriverExecutable con la ruta al ChromeDriver 148 descargado manualmente.

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):

Árbol de Inspector en contexto NATIVE_APP mostrando android.webkit.WebView con hijos android.widget.EditText y android.widget.Button
Contexto NATIVE_APP. Todo son widgets Android: EditText, Button, TextView. UiAutomator2 mapea el HTML a clases nativas.
<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):

Árbol de Inspector en contexto WEBVIEW mostrando DOM HTML con body, div, form, input id user-name y input id login-button
Contexto WEBVIEW. El DOM HTML real de saucedemo.com: form, input, div. Selenium puro.
<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

IntelliJ IDEA con resultado de 30 tests pasados incluyendo WebViewTest con 5 tests, CartTest, LoginTest, GestosTest, CheckoutTest, PermisosTest, LifecycleTest y ProductDetailTest
30 de 30. Los 5 tests de WebView verdes, y los 25 existentes no se rompieron con el cambio en BaseTest.
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

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