¿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:
- Detecta el CAPTCHA en el WebView durante la prueba.
- Extrae la clave del sitio mediante programación
- Lo resuelve vía CaptchaAI
- 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
- Resolver el callback de reCAPTCHA v2 con la API
- Automatización sin código de CaptchaAI con Zapier
- reCAPTCHA v2 y Turnstile en un mismo sitio
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: