Integraciones

Manejo de CAPTCHA de automatización de iOS con XCUITest y CaptchaAI

¿Tu suite de XCUITest se detiene cuando aparece un reCAPTCHA v2 dentro de un WKWebView? La solución no es marcar la casilla a mano: consiste en montar un puente que detecte el CAPTCHA, lo resuelva con CaptchaAI y devuelva el token para que el formulario se envíe.

Por qué el CAPTCHA frena tus pruebas de UI

Un WKWebView es una caja negra para XCUITest: el framework lo ve como un único elemento de accesibilidad y no expone los nodos internos del reCAPTCHA. Sin acceso al DOM no puedes leer el data-sitekey ni disparar su callback de validación.

El montaje reúne tres piezas:

  • un hook de prueba que evalúa JavaScript;
  • un servicio puente que llama a CaptchaAI por HTTPS;
  • la prueba de XCUITest que orquesta el flujo.

Arquitectura del montaje

Cada pieza tiene una responsabilidad única:

Componente Rol
XCUITest Controla la interfaz de usuario y activa la resolución del CAPTCHA a través del asistente de prueba
API auxiliar de prueba Recibe la clave del sitio + URL, llama a CaptchaAI y devuelve el token
Hook de prueba en la app Evalúa JavaScript en el WKWebView para detección e inyección
API CaptchaAI Resuelve el desafío CAPTCHA

Un caso habitual: registro con reCAPTCHA v2

El equipo de QA de una fintech latinoamericana valida el alta de cuenta en cada release. La app carga el registro en un WKWebView protegido con reCAPTCHA v2 y la prueba nocturna se cuelga.

Necesitas un montaje que:

  1. Detecta el CAPTCHA en el WebView durante la prueba.
  2. Extrae la clave del sitio mediante programación
  3. Lo resuelve vía CaptchaAI
  4. Inyecta el token para que el formulario pueda enviarse

Entorno de referencia:

  • Xcode 15+, Swift y XCUITest;
  • ejecutor de pruebas en macOS;
  • la API de CaptchaAI con una API key activa.

Paso 1: añade un hook de prueba a la app

En el controlador del WKWebView de tu app, añade un manejador de CAPTCHA que solo exista en modo de prueba, activable por accesibilidad y envuelto en #if DEBUG. Expone tres operaciones:

  • detectar el sitekey;
  • pedir el token al backend;
  • inyectar el token en la página.
// CaptchaTestHelper.swift — Add to app target (test build only)
import WebKit

#if DEBUG
class CaptchaTestHelper {
    private let webView: WKWebView

    init(webView: WKWebView) {
        self.webView = webView
    }

    func detectCaptcha(completion: @escaping (String?, String?) -> Void) {
        let script = """
        (function() {
            var el = document.querySelector('.g-recaptcha');
            if (el) {
                return JSON.stringify({
                    sitekey: el.getAttribute('data-sitekey'),
                    pageurl: window.location.href
                });
            }
            return null;
        })();
        """

        webView.evaluateJavaScript(script) { result, error in
            guard let jsonString = result as? String,
                  let data = jsonString.data(using: .utf8),
                  let json = try? JSONSerialization.jsonObject(with: data) as? [String: String] else {
                completion(nil, nil)
                return
            }
            completion(json["sitekey"], json["pageurl"])
        }
    }

    func injectToken(_ token: String, completion: @escaping (Bool) -> Void) {
        let script = """
        document.getElementById('g-recaptcha-response').value = '\(token)';
        try {
            var clients = ___grecaptcha_cfg.clients;
            Object.keys(clients).forEach(function(k) {
                Object.keys(clients[k]).forEach(function(j) {
                    if (clients[k][j] && clients[k][j].callback) {
                        clients[k][j].callback('\(token)');
                    }
                });
            });
        } catch(e) {}
        true;
        """

        webView.evaluateJavaScript(script) { _, error in
            completion(error == nil)
        }
    }

    func solveCaptchaViaBackend(
        sitekey: String, pageurl: String,
        completion: @escaping (Result<String, Error>) -> Void
    ) {
        guard let url = URL(string: "http://localhost:3000/api/solve-captcha") else {
            return
        }

        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")

        let body: [String: String] = [
            "captchaType": "recaptcha_v2",
            "sitekey": sitekey,
            "pageurl": pageurl
        ]
        request.httpBody = try? JSONSerialization.data(withJSONObject: body)

        URLSession.shared.dataTask(with: request) { data, _, error in
            if let error = error {
                completion(.failure(error))
                return
            }
            guard let data = data,
                  let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
                  let token = json["token"] as? String else {
                completion(.failure(NSError(domain: "", code: -1,
                    userInfo: [NSLocalizedDescriptionKey: "No token"])))
                return
            }
            completion(.success(token))
        }.resume()
    }
}
#endif

Paso 2: el servicio puente que habla con CaptchaAI

Levanta un servicio de resolución local en la máquina de pruebas. Recibe el sitekey y la URL, envía la tarea a CaptchaAI con userrecaptcha sobre in.php, sondea res.php y devuelve el token.

Mantén tu API key en una variable de entorno.

# ios_test_solver.py — Run on test machine during XCUITest execution
import os
import time
import requests
from flask import Flask, request, jsonify

app = Flask(__name__)
API_KEY = os.environ.get("CAPTCHAAI_API_KEY", "YOUR_API_KEY")

@app.route("/api/solve-captcha", methods=["POST"])
def solve():
    data = request.json
    sitekey = data["sitekey"]
    pageurl = data["pageurl"]

    # Submit to CaptchaAI
    resp = requests.get("https://ocr.captchaai.com/in.php", params={
        "key": API_KEY,
        "method": "userrecaptcha",
        "googlekey": sitekey,
        "pageurl": pageurl,
        "json": "1",
    })
    result = resp.json()

    if result.get("status") != 1:
        return jsonify({"error": result.get("request")}), 400

    task_id = result["request"]

    # Poll
    for _ in range(30):
        time.sleep(5)
        poll = requests.get("https://ocr.captchaai.com/res.php", params={
            "key": API_KEY,
            "action": "get",
            "id": task_id,
            "json": "1",
        })
        poll_result = poll.json()
        if poll_result.get("status") == 1:
            return jsonify({"token": poll_result["request"]})
        if poll_result.get("request") != "CAPCHA_NOT_READY":
            return jsonify({"error": poll_result["request"]}), 400

    return jsonify({"error": "Timeout"}), 408

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=3000)

Contrato entre el hook y el backend

El hook envía un JSON con el tipo, el sitekey y la URL:

{
    "captchaType": "recaptcha_v2",
    "sitekey": "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI",
    "pageurl": "https://example.com/register"
}

Y recibe de vuelta el token que se inyectará en el formulario:

{
    "token": "03AFcWeA..."
}

Paso 3: orquesta el flujo desde XCUITest

Con el hook y el servicio listos, la prueba lanza la app en modo de prueba, navega al registro, espera a que cargue el WebView y dispara la resolución.

Deja un margen generoso en el waitForExistence del indicador de éxito: resolver un reCAPTCHA v2 no es instantáneo.

// CaptchaUITests.swift
import XCTest

class CaptchaUITests: XCTestCase {

    func testRegistrationWithCaptcha() throws {
        let app = XCUIApplication()
        app.launchArguments.append("--captcha-test-mode")
        app.launch()

        // Navigate to registration
        app.buttons["Register"].tap()

        // Wait for WebView to load
        let webView = app.webViews.firstMatch
        XCTAssertTrue(webView.waitForExistence(timeout: 15))

        // Trigger CAPTCHA solve via test helper button
        // (The app shows this button only in test mode)
        let solveButton = app.buttons["SolveCaptchaTestHelper"]
        if solveButton.waitForExistence(timeout: 5) {
            solveButton.tap()

            // Wait for solve completion indicator
            let solved = app.staticTexts["CaptchaSolved"]
            XCTAssertTrue(solved.waitForExistence(timeout: 120),
                "CAPTCHA should be solved within 2 minutes")
        }

        // Continue with form submission
        app.buttons["SubmitForm"].tap()

        // Verify success
        let success = app.staticTexts["Registration Complete"]
        XCTAssertTrue(success.waitForExistence(timeout: 10))
    }
}

Errores frecuentes y cómo resolverlos

Cuatro problemas concentran casi todos los fallos de este montaje:

Problema Causa Solución
evaluateJavaScript devuelve cero El WebView no ha terminado de cargarse Espera a que webView.isLoading == false antes de inyectar JS
Backend no accesible desde el Simulator localhost no accesible Usa 127.0.0.1 o la IP de red del Mac; revisa App Transport Security
La inyección del token no dispara el callback callback de reCAPTCHA anidado en un objeto complejo Recorre de forma recursiva todas las propiedades de ___grecaptcha_cfg.clients
XCUITest agota el tiempo de espera de la solución Resolución de CaptchaAI prolongada Fija el tiempo de espera de la prueba por encima de 120 segundos

Preguntas frecuentes

¿Qué plan de CaptchaAI conviene para automatizar pruebas de iOS?

Facturas por thread concurrente, no por resolución, con un coste mensual en USD predecible:

  • BASIC ($15/mes, 5 threads): suficiente para una suite de CI con pocos CAPTCHA;
  • STANDARD ($30/mes, 15 threads): cuando paralelizas muchos dispositivos a la vez.

¿Cómo ajusto el tiempo de espera si el reCAPTCHA tarda en resolverse?

Sube el timeout del waitForExistence a 120 segundos o más. El servicio puente sondea res.php cada cinco segundos, así que la prueba debe tolerar ese margen.

¿Sirve este enfoque si el WebView pertenece a un SDK de terceros?

Depende: si no controlas el WebView no puedes añadir el hook #if DEBUG. Cambia entonces a Appium, que expone execute_script sin instrumentar la app.

¿Puedo resolver también Cloudflare Turnstile o reCAPTCHA v3 con este montaje?

Sí. La arquitectura no cambia: solo ajustas el selector del hook y el captchaType que envías al backend. Con el mismo puente, CaptchaAI resuelve:

  • reCAPTCHA v2 y v3;
  • Cloudflare Turnstile y Challenge;
  • GeeTest v3 e imágenes/OCR.

Artículos relacionados

Próximos pasos

Lleva CaptchaAI a tu pipeline de pruebas de iOS: obtén tu API key y automatiza los flujos protegidos por CAPTCHA.

Guías relacionadas:

Los comentarios están deshabilitados para este artículo.