O WKWebView não expõe uma ponte de JavaScript para o XCUITest — então, quando um formulário carregado nesse WebView traz um reCAPTCHA v2, o teste trava ali até alguém resolver o desafio manualmente. A saída é resolver o CAPTCHA por fora: um serviço auxiliar chama a API da CaptchaAI, recebe o token de volta e você injeta esse token no WebView via JavaScript, como o callback do reCAPTCHA espera.
Este guia percorre três peças:
- um gancho de teste dentro do próprio app, ativo só na build de debug
- um serviço solucionador em Python que conversa com a CaptchaAI
- a chamada no XCUITest que aciona tudo isso durante a execução do teste
Arquitetura: uma ponte fora do WebView
O XCUITest não consegue rodar JavaScript diretamente dentro de um WKWebView. A solução usa um endpoint auxiliar que o próprio app chama durante o teste:
- Teste XCUITest — conduz a UI e aciona a resolução do CAPTCHA por meio do auxiliar de teste
- API auxiliar de teste — recebe sitekey + URL, chama a CaptchaAI, devolve o token
- Gancho de teste do app — avalia JavaScript no WKWebView para detectar e injetar
- API da CaptchaAI — resolve o desafio CAPTCHA
Onde o CAPTCHA trava o teste de iOS
Um cenário comum: o app carrega uma tela de cadastro num WKWebView, e o formulário embute reCAPTCHA v2 — típico de apps que reaproveitam um formulário web para login, pagamento ou onboarding. Na suíte de testes de UI, esse desafio interrompe o fluxo antes da tela de confirmação.
Para destravar isso sem intervenção manual, o teste precisa passar por quatro etapas: detectar o CAPTCHA no WebView durante a execução, extrair a sitekey programaticamente, resolver o desafio via CaptchaAI e injetar o token de volta para o formulário ser enviado.
Ambiente: Xcode 15+, Swift, XCUITest, executor de testes macOS, API da CaptchaAI.
Passo 1: adicione um gancho de teste ao app
No controlador do WKWebView do seu app, adicione um manipulador de CAPTCHA ativo somente em modo de teste, acionável por identificador de acessibilidade ou esquema de URL:
// 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
Passo 2: o serviço solucionador no back-end
Rode um serviço de resolução local durante o teste, responsável por conversar com a CaptchaAI:
# 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)
Passo 3: a chamada no XCUITest
No teste, acione a resolução do CAPTCHA assim que o WebView com o desafio terminar de carregar:
// 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))
}
}
Perguntas frequentes
Como evito expor a chave de API da CaptchaAI no repositório de testes?
Nunca hardcode a chave no código-fonte. Guarde-a em um destes dois lugares:
- Uma variável de ambiente do CI, lida como no exemplo
os.environ.get("CAPTCHAAI_API_KEY", ...). - Um secret do próprio provedor de CI (GitHub Actions, Bitrise), nunca commitado no repositório.
O serviço solucionador precisa rodar na mesma máquina do simulador?
Não precisa, só uma rede acessível pelo Simulador. Em CI hospedado, é mais simples apontar para 127.0.0.1 ou localhost no mesmo executor macOS.
Quanto tempo esse fluxo acrescenta à suíte de testes?
Depende de dois fatores:
- o tipo de CAPTCHA e a fila da CaptchaAI no momento da execução
- 120 s de timeout por teste, no mínimo — melhor superestimar do que ver o CI falhar num dia de pico
Esse fluxo funciona em runners de CI, como GitHub Actions ou Bitrise?
Sim, desde que o back-end solucionador rode na mesma máquina do runner (ou do Simulador).
O XCUITest consegue executar JavaScript diretamente no WKWebView?
Não — e essa é a raiz do problema:
- interage com elementos de UI (botões, campos, textos)
- não avalia JavaScript dentro do WKWebView
Por isso o gancho de teste no código do app (só em build de debug) é o que fecha essa lacuna.
Contrato da ponte e problemas comuns
- Defina o payload que o teste envia ao serviço auxiliar: URL de destino e metadados do desafio.
- Devolva uma resposta estruturada com token, expiração e motivo do erro, para a camada de teste ramificar sem travar.
- Ligue os logs do simulador aos do serviço auxiliar por um identificador de rastreamento comum — isso acelera a triagem de falhas intermitentes. Se algum log guardar dado de um usuário de teste real, e não só dado fictício de QA, trate-o como dado pessoal sob a LGPD e não o deixe em um pipeline de CI público.
Problemas frequentes nessa ponte:
evaluateJavaScriptretorna nulo — o WebView não terminou de carregar; aguardewebView.isLoading == falseantes de injetar o JS.- Simulador não alcança o back-end —
localhostnão é acessível a partir do Simulador; use127.0.0.1ou o IP de rede do Mac e confira o App Transport Security. - Token injetado não dispara o callback — o callback do reCAPTCHA está aninhado em objeto complexo; percorra recursivamente as propriedades de
___grecaptcha_cfg.clients. - XCUITest estoura o tempo limite — a resolução da CaptchaAI variou naquela execução; configure o timeout do teste para 120 s ou mais.
Próximos passos
Leve esse fluxo para o seu pipeline de testes de iOS — obtenha sua chave de API e automatize os fluxos protegidos por CAPTCHA na sua suíte. Guias relacionados:
- Manipulação de CAPTCHA na automação de apps móveis com Appium
- Manipulação de CAPTCHA no WebView do Flutter
- Extraindo parâmetros do reCAPTCHA a partir do código-fonte da página
Artigos relacionados
- Como resolver o callback do reCAPTCHA v2 pela API
- Automação sem código do Zapier com a CaptchaAI
- reCAPTCHA v2 e Turnstile no mesmo site